mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-24 19:35:33 +00:00
Merge pull request #718 from simplex-chat/master (version 2.2.0)
This commit is contained in:
+5
@@ -1,5 +1,8 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<ComposeCustomCodeStyleSettings>
|
||||
<option name="USE_CUSTOM_FORMATTING_FOR_MODIFIERS" value="false" />
|
||||
</ComposeCustomCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
|
||||
@@ -123,9 +126,11 @@
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="RIGHT_MARGIN" value="140" />
|
||||
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="0" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
||||
<option name="EXTENDS_LIST_WRAP" value="0" />
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 34
|
||||
versionName "2.1.1"
|
||||
versionCode 36
|
||||
versionName "2.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
@@ -76,6 +76,7 @@ dependencies {
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'androidx.fragment:fragment:1.4.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
@@ -103,6 +104,9 @@ dependencies {
|
||||
// Link Previews
|
||||
implementation 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
// Biometric authentication
|
||||
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
@@ -64,6 +65,9 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".views.call.IncomingCallActivity"
|
||||
android:showOnLockScreen="true"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="chat.simplex.app.provider"
|
||||
|
||||
@@ -96,7 +96,7 @@ const processCommand = (function () {
|
||||
const pc = new RTCPeerConnection(config.peerConnectionConfig);
|
||||
const remoteStream = new MediaStream();
|
||||
const localCamera = VideoCamera.User;
|
||||
const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType, localCamera));
|
||||
const localStream = await getLocalMediaStream(mediaType, localCamera);
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||
await setupMediaStreams(call);
|
||||
@@ -116,8 +116,10 @@ const processCommand = (function () {
|
||||
});
|
||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
if (activeCall) {
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
}
|
||||
endCall();
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
}
|
||||
else if (pc.connectionState == "connected") {
|
||||
const stats = (await pc.getStats());
|
||||
@@ -133,7 +135,7 @@ const processCommand = (function () {
|
||||
remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId),
|
||||
},
|
||||
};
|
||||
setTimeout(() => sendMessageToNative({ resp }), 0);
|
||||
setTimeout(() => sendMessageToNative({ resp }), 500);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -153,11 +155,17 @@ const processCommand = (function () {
|
||||
try {
|
||||
switch (command.type) {
|
||||
case "capabilities":
|
||||
console.log("starting outgoing call - capabilities");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
// This request for local media stream is made to prompt for camera/mic permissions on call start
|
||||
if (command.media)
|
||||
await getLocalMediaStream(command.media, VideoCamera.User);
|
||||
const encryption = supportsInsertableStreams(command.useWorker);
|
||||
resp = { type: "capabilities", capabilities: { encryption } };
|
||||
break;
|
||||
case "start": {
|
||||
console.log("starting call");
|
||||
console.log("starting incoming call - create webrtc session");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
const { media, useWorker, iceServers, relay } = command;
|
||||
@@ -256,19 +264,9 @@ const processCommand = (function () {
|
||||
if (!activeCall || !pc) {
|
||||
resp = { type: "error", message: "camera: call not started" };
|
||||
}
|
||||
else if (activeCall.localMedia == CallMediaType.Audio) {
|
||||
resp = { type: "error", message: "camera: no video" };
|
||||
}
|
||||
else {
|
||||
try {
|
||||
if (command.camera != activeCall.localCamera) {
|
||||
await replaceCamera(activeCall, command.camera);
|
||||
}
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
catch (e) {
|
||||
resp = { type: "error", message: `camera: ${e.message}` };
|
||||
}
|
||||
await replaceMedia(activeCall, command.camera);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "end":
|
||||
@@ -281,7 +279,7 @@ const processCommand = (function () {
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
resp = { type: "error", message: e.message };
|
||||
resp = { type: "error", message: `${command.type}: ${e.message}` };
|
||||
}
|
||||
const apiResp = { corrId, resp, command };
|
||||
sendMessageToNative(apiResp);
|
||||
@@ -323,6 +321,8 @@ const processCommand = (function () {
|
||||
if (call.useWorker && !call.worker) {
|
||||
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
|
||||
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
|
||||
call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message }));
|
||||
call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,14 +346,20 @@ const processCommand = (function () {
|
||||
// Pull tracks from remote stream as they arrive add them to remoteStream video
|
||||
const pc = call.connection;
|
||||
pc.ontrack = (event) => {
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up decryption for receiving");
|
||||
setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
for (const stream of event.streams) {
|
||||
for (const track of stream.getTracks()) {
|
||||
call.remoteStream.addTrack(track);
|
||||
try {
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up decryption for receiving");
|
||||
setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
for (const stream of event.streams) {
|
||||
for (const track of stream.getTracks()) {
|
||||
call.remoteStream.addTrack(track);
|
||||
}
|
||||
}
|
||||
console.log(`ontrack success`);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`ontrack error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -385,7 +391,7 @@ const processCommand = (function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function replaceCamera(call, camera) {
|
||||
async function replaceMedia(call, camera) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
@@ -393,14 +399,15 @@ const processCommand = (function () {
|
||||
for (const t of call.localStream.getTracks())
|
||||
t.stop();
|
||||
call.localCamera = camera;
|
||||
const constraints = callMediaConstraints(call.localMedia, camera);
|
||||
const localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const localStream = await getLocalMediaStream(call.localMedia, camera);
|
||||
replaceTracks(pc, localStream.getVideoTracks());
|
||||
replaceTracks(pc, localStream.getAudioTracks());
|
||||
call.localStream = localStream;
|
||||
videos.local.srcObject = localStream;
|
||||
}
|
||||
function replaceTracks(pc, tracks) {
|
||||
if (!tracks.length)
|
||||
return;
|
||||
const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; });
|
||||
if (sender)
|
||||
for (const t of tracks)
|
||||
@@ -427,6 +434,10 @@ const processCommand = (function () {
|
||||
console.log(`no ${operation}`);
|
||||
}
|
||||
}
|
||||
function getLocalMediaStream(mediaType, facingMode) {
|
||||
const constraints = callMediaConstraints(mediaType, facingMode);
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
function callMediaConstraints(mediaType, facingMode) {
|
||||
switch (mediaType) {
|
||||
case CallMediaType.Audio:
|
||||
@@ -494,8 +505,8 @@ function callCryptoFunction() {
|
||||
const initial = data.subarray(0, n);
|
||||
const plaintext = data.subarray(n, data.byteLength);
|
||||
try {
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext);
|
||||
frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer;
|
||||
const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0);
|
||||
frame.data = concatN(initial, ciphertext, iv).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -512,8 +523,8 @@ function callCryptoFunction() {
|
||||
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
|
||||
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
|
||||
try {
|
||||
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
||||
frame.data = concatN(initial, new Uint8Array(plaintext)).buffer;
|
||||
const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0);
|
||||
frame.data = concatN(initial, plaintext).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -619,9 +630,15 @@ function workerFunction() {
|
||||
// encryption using RTCRtpScriptTransform.
|
||||
if ("RTCTransformEvent" in self) {
|
||||
self.addEventListener("rtctransform", async ({ transformer }) => {
|
||||
const { operation, aesKey } = transformer.options;
|
||||
const { readable, writable } = transformer;
|
||||
await setupTransform({ operation, aesKey, readable, writable });
|
||||
try {
|
||||
const { operation, aesKey } = transformer.options;
|
||||
const { readable, writable } = transformer;
|
||||
await setupTransform({ operation, aesKey, readable, writable });
|
||||
self.postMessage({ result: "setupTransform success" });
|
||||
}
|
||||
catch (e) {
|
||||
self.postMessage({ message: `setupTransform error: ${e.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function setupTransform({ operation, aesKey, readable, writable }) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
video::-webkit-media-controls {
|
||||
display: none;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -24,3 +22,20 @@ body {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
@@ -4,24 +4,31 @@ import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Replay
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.SplashView
|
||||
import chat.simplex.app.views.call.ActiveCallView
|
||||
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||
import chat.simplex.app.views.chat.ChatView
|
||||
import chat.simplex.app.views.chatlist.ChatListView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
@@ -29,18 +36,22 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.connectViaUri
|
||||
import chat.simplex.app.views.newchat.withUriAction
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
//import kotlinx.serialization.decodeFromString
|
||||
|
||||
class MainActivity: ComponentActivity() {
|
||||
class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
private val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
private val enteredBackground = mutableStateOf<Long?>(null)
|
||||
private val laFailed = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// testJson()
|
||||
processNotificationIntent(intent, vm.chatModel)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
processNotificationIntent(intent, m)
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
@@ -48,7 +59,14 @@ class MainActivity: ComponentActivity() {
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
MainPage(vm.chatModel)
|
||||
MainPage(
|
||||
m,
|
||||
userAuthorized,
|
||||
laFailed,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { m.controller.showLANotice(this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,14 +78,66 @@ class MainActivity: ComponentActivity() {
|
||||
processIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuthenticate() {
|
||||
val m = vm.chatModel
|
||||
if (!m.controller.appPrefs.performLA.get()) {
|
||||
userAuthorized.value = true
|
||||
} else {
|
||||
userAuthorized.value = false
|
||||
ModalManager.shared.closeModals()
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_unlock),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
userAuthorized.value = true
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
laFailed.value = true
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = chatController.getAutoRestartWorkerVersion()
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.setAutoRestartWorkerVersion(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
@@ -77,6 +147,82 @@ class MainActivity: ComponentActivity() {
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||
@@ -85,16 +231,85 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage(chatModel: ChatModel) {
|
||||
fun MainPage(
|
||||
chatModel: ChatModel,
|
||||
userAuthorized: MutableState<Boolean?>,
|
||||
laFailed: MutableState<Boolean>,
|
||||
runAuthenticate: () -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(userAuthorized.value) {
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
delay(500L)
|
||||
}
|
||||
chatsAccessAuthorized = userAuthorized.value == true
|
||||
}
|
||||
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.isNotEmpty()
|
||||
&& chatModel.activeCallInvitation.value == null
|
||||
) {
|
||||
showLANotice()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value) {
|
||||
ModalManager.shared.closeModals()
|
||||
chatModel.clearOverlays.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun retryAuthView() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_retry),
|
||||
icon = Icons.Outlined.Replay,
|
||||
click = {
|
||||
laFailed.value = false
|
||||
runAuthenticate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
val onboarding = chatModel.onboardingStage.value
|
||||
val userCreated = chatModel.userCreated.value
|
||||
when {
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated ->
|
||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||
else if (chatModel.chatId.value == null) ChatListView(chatModel)
|
||||
else ChatView(chatModel)
|
||||
!chatsAccessAuthorized -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
retryAuthView()
|
||||
} else {
|
||||
SplashView()
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||
Box {
|
||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||
else {
|
||||
showAdvertiseLAAlert = true
|
||||
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) })
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo ->
|
||||
Box(Modifier.padding(horizontal = 20.dp)) {
|
||||
SimpleXInfo(chatModel, onboarding = true)
|
||||
@@ -102,6 +317,8 @@ fun MainPage(chatModel: ChatModel) {
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
}
|
||||
@@ -122,6 +339,18 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
}
|
||||
NtfManager.AcceptCallAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
if (chatId == null || chatId == "") return
|
||||
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||
chatModel.clearOverlays.value = true
|
||||
val invitation = chatModel.callInvitations[chatId]
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +392,6 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
|
||||
@@ -30,7 +30,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl) : String
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val chatController: ChatController by lazy {
|
||||
val ctrl = chatInit(getFilesDirectory(applicationContext))
|
||||
ChatController(ctrl, ntfManager, applicationContext)
|
||||
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
val chatModel: ChatModel by lazy {
|
||||
@@ -38,7 +38,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
|
||||
private val ntfManager: NtfManager by lazy {
|
||||
NtfManager(applicationContext)
|
||||
NtfManager(applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
private val appPreferences: AppPreferences by lazy {
|
||||
AppPreferences(applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -62,7 +66,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP ->
|
||||
if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
|
||||
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
|
||||
Lifecycle.Event.ON_START ->
|
||||
SimplexService.start(applicationContext)
|
||||
Lifecycle.Event.ON_RESUME ->
|
||||
|
||||
@@ -8,8 +8,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SecretColor
|
||||
import chat.simplex.app.ui.theme.SimplexBlue
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
@@ -38,14 +37,20 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
// set when app is opened via contact or invitation URI
|
||||
val appOpenUrl = mutableStateOf<Uri?>(null)
|
||||
|
||||
// preferences
|
||||
val runServiceInBackground = mutableStateOf(true)
|
||||
val performLA = mutableStateOf(false)
|
||||
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||
|
||||
// current WebRTC call
|
||||
val callManager = CallManager(this)
|
||||
val callInvitations = mutableStateMapOf<String, CallInvitation>()
|
||||
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
|
||||
val activeCallInvitation = mutableStateOf<CallInvitation?>(null)
|
||||
val activeCall = mutableStateOf<Call?>(null)
|
||||
val callCommand = mutableStateOf<WCallCommand?>(null)
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
||||
fun updateUserProfile(profile: Profile) {
|
||||
val user = currentUser.value
|
||||
@@ -457,10 +462,10 @@ class Connection(val connId: Long, val connStatus: ConnStatus) {
|
||||
|
||||
@Serializable
|
||||
class Profile(
|
||||
val displayName: String,
|
||||
val fullName: String,
|
||||
val image: String? = null
|
||||
) {
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
): NamedChat {
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
displayName = "alice",
|
||||
@@ -687,13 +692,6 @@ data class ChatItem (
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||
else null
|
||||
|
||||
val isMsgContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndMsgContent -> true
|
||||
is CIContent.RcvMsgContent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isDeletedContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndDeleted -> true
|
||||
@@ -845,10 +843,11 @@ sealed class CIContent: ItemContent {
|
||||
|
||||
@Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
|
||||
@Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
|
||||
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
|
||||
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
|
||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
|
||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
|
||||
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
|
||||
override val text: String get() = when(this) {
|
||||
is SndMsgContent -> msgContent.text
|
||||
@@ -857,6 +856,7 @@ sealed class CIContent: ItemContent {
|
||||
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
||||
is SndCall -> status.text(duration)
|
||||
is RcvCall -> status.text(duration)
|
||||
is RcvIntegrityError -> msgError.text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,7 +1080,7 @@ enum class FormatColor(val color: String) {
|
||||
|
||||
val uiColor: Color @Composable get() = when (this) {
|
||||
red -> Color.Red
|
||||
green -> Color.Green
|
||||
green -> SimplexGreen
|
||||
blue -> SimplexBlue
|
||||
yellow -> Color.Yellow
|
||||
cyan -> Color.Cyan
|
||||
@@ -1120,3 +1120,18 @@ enum class CICallStatus {
|
||||
|
||||
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class MsgErrorType() {
|
||||
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
|
||||
@Serializable @SerialName("msgBadId") class MsgBadId(val msgId: Long): MsgErrorType()
|
||||
@Serializable @SerialName("msgBadHash") class MsgBadHash(): MsgErrorType()
|
||||
@Serializable @SerialName("msgDuplicate") class MsgDuplicate(): MsgErrorType()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is MsgSkipped -> String.format(generalGetString(R.string.integrity_msg_skipped), toMsgId - fromMsgId + 1)
|
||||
is MsgBadHash -> generalGetString(R.string.integrity_msg_bad_hash) // not used now
|
||||
is MsgBadId -> generalGetString(R.string.integrity_msg_bad_id) // not used now
|
||||
is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.call.CallInvitation
|
||||
import chat.simplex.app.views.call.CallMediaType
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class NtfManager(val context: Context) {
|
||||
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||
companion object {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -24,11 +33,22 @@ class NtfManager(val context: Context) {
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(
|
||||
MessageChannel,
|
||||
"SimpleX Chat messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
))
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel())
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(): NotificationChannel {
|
||||
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.build()
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||
callChannel.setSound(soundUri, attrs)
|
||||
callChannel.enableVibration(true)
|
||||
return callChannel
|
||||
}
|
||||
|
||||
fun cancelNotificationsForChat(chatId: String) {
|
||||
@@ -44,21 +64,25 @@ class NtfManager(val context: Context) {
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
Log.d(TAG, "notifyMessageReceived ${cInfo.id}")
|
||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(cInfo.id, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[cInfo.id] = now
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[chatId] = now
|
||||
|
||||
val notification = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(cInfo.displayName)
|
||||
.setContentText(hideSecrets(cItem))
|
||||
.setContentTitle(displayName)
|
||||
.setContentText(msgText)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(getMsgPendingIntent(cInfo.id))
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||
.setSilent(recentNotification)
|
||||
.build()
|
||||
|
||||
@@ -68,47 +92,62 @@ class NtfManager(val context: Context) {
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setGroupSummary(true)
|
||||
.setContentIntent(getSummaryNtfIntent())
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
notify(cInfo.id.hashCode(), notification)
|
||||
notify(chatId.hashCode(), notification)
|
||||
notify(0, summary)
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyCallInvitation(contact: Contact, invitation: CallInvitation) {
|
||||
Log.d(TAG, "notifyCallInvitationReceived ${contact.id}")
|
||||
|
||||
val notification = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(contact.displayName)
|
||||
.setContentText("Incoming ${invitation.peerMedia} call (${if (invitation.sharedKey == null) "not e2e encrypted" else "e2e encrypted"})")
|
||||
fun notifyCallInvitation(invitation: CallInvitation) {
|
||||
if (isAppOnForeground(context)) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
val image = invitation.contact.image
|
||||
var ntfBuilder =
|
||||
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(true)
|
||||
} else {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.setSound(soundUri)
|
||||
}
|
||||
val text = generalGetString(
|
||||
if (invitation.peerMedia == CallMediaType.Video) {
|
||||
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
} else {
|
||||
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
}
|
||||
)
|
||||
ntfBuilder = ntfBuilder
|
||||
.setContentTitle(invitation.contact.displayName)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(getMsgPendingIntent(contact.id))
|
||||
.setSilent(false)
|
||||
.build()
|
||||
|
||||
// val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||
// .setSmallIcon(R.drawable.ntf_icon)
|
||||
// .setColor(0x88FFFF)
|
||||
// .setGroup(MessageGroup)
|
||||
// .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
// .setGroupSummary(true)
|
||||
// .setContentIntent(getSummaryNtfIntent())
|
||||
// .build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
notify(0, notification)
|
||||
// notify(0, summary)
|
||||
notify(CallNotificationId, ntfBuilder.build())
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelCallNotification() {
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md == null) {
|
||||
@@ -126,25 +165,13 @@ class NtfManager(val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMsgPendingIntent(chatId: String) : PendingIntent{
|
||||
Log.d(TAG, "getMsgPendingIntent $chatId")
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
var intent = Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra("chatId", chatId)
|
||||
.setAction(OpenChatAction)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSummaryNtfIntent() : PendingIntent{
|
||||
Log.d(TAG, "getSummaryNtfIntent")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(ShowChatsAction)
|
||||
.setAction(intentAction)
|
||||
if (chatId != null) intent = intent.putExtra("chatId", chatId)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
@@ -18,13 +18,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.*
|
||||
@@ -34,12 +34,95 @@ import kotlin.concurrent.thread
|
||||
|
||||
typealias ChatCtrl = Long
|
||||
|
||||
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context) {
|
||||
var chatModel = ChatModel(this)
|
||||
private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
fun isAppOnForeground(context: Context): Boolean {
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||
val packageName = context.packageName
|
||||
for (appProcess in appProcesses) {
|
||||
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
enum class CallOnLockScreen {
|
||||
DISABLE,
|
||||
SHOW,
|
||||
ACCEPT;
|
||||
|
||||
companion object {
|
||||
val default = SHOW
|
||||
}
|
||||
}
|
||||
|
||||
class AppPreferences(val context: Context) {
|
||||
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
|
||||
val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
||||
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
|
||||
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||
val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
|
||||
private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
|
||||
val callOnLockScreen: Preference<CallOnLockScreen> = Preference(
|
||||
get = fun(): CallOnLockScreen {
|
||||
val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
|
||||
return try {
|
||||
CallOnLockScreen.valueOf(value)
|
||||
} catch (e: Error) {
|
||||
CallOnLockScreen.default
|
||||
}
|
||||
},
|
||||
set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) }
|
||||
)
|
||||
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
|
||||
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
|
||||
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
|
||||
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
|
||||
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
Preference(
|
||||
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkBoolPreference(prefName: String, default: Boolean) =
|
||||
Preference(
|
||||
get = fun() = sharedPreferences.getBoolean(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkStrPreference(prefName: String, default: String?): Preference<String?> =
|
||||
Preference(
|
||||
get = fun() = sharedPreferences.getString(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
||||
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
|
||||
private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay"
|
||||
private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen"
|
||||
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
|
||||
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
|
||||
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
|
||||
val chatModel = ChatModel(this)
|
||||
|
||||
init {
|
||||
chatModel.runServiceInBackground.value = getRunServiceInBackground()
|
||||
chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get()
|
||||
chatModel.performLA.value = appPrefs.performLA.get()
|
||||
}
|
||||
|
||||
suspend fun startChat(user: User) {
|
||||
@@ -69,22 +152,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
fun startReceiver() {
|
||||
Log.d(TAG, "ChatController startReceiver")
|
||||
thread(name="receiver") {
|
||||
withApi { recvMspLoop() }
|
||||
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
|
||||
}
|
||||
}
|
||||
|
||||
open fun isAppOnForeground(context: Context): Boolean {
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||
val packageName = context.packageName
|
||||
for (appProcess in appProcesses) {
|
||||
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun sendCmd(cmd: CC): CR {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val c = cmd.cmdString
|
||||
@@ -446,15 +517,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
val cItem = r.chatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
val file = cItem.file
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) {
|
||||
withApi {
|
||||
val chatItem = apiReceiveFile(file.fileId)
|
||||
if (chatItem != null) {
|
||||
chatItemSimpleUpdate(chatItem)
|
||||
}
|
||||
}
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
}
|
||||
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
|
||||
if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
@@ -501,48 +567,21 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
}
|
||||
is CR.CallInvitation -> {
|
||||
val invitation = CallInvitation(r.callType.media, r.sharedKey)
|
||||
chatModel.callInvitations[r.contact.id] = invitation
|
||||
if (chatModel.activeCallInvitation.value == null) {
|
||||
chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName)
|
||||
}
|
||||
ntfManager.notifyCallInvitation(r.contact, invitation)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = invitation.callTitle,
|
||||
text = String.format(generalGetString(R.string.contact_wants_to_connect_via_call), r.contact.displayName) + " " + invitation.callTypeText + ".",
|
||||
confirmText = generalGetString(R.string.answer),
|
||||
onConfirm = {
|
||||
if (chatModel.activeCallInvitation.value == null) {
|
||||
AlertManager.shared.hideAlert()
|
||||
withApi { AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) }
|
||||
} else {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.activeCall.value = Call(
|
||||
contact = r.contact,
|
||||
callState = CallState.InvitationReceived,
|
||||
localMedia = invitation.peerMedia,
|
||||
sharedKey = invitation.sharedKey
|
||||
)
|
||||
chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey)
|
||||
chatModel.showCallView.value = true
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
}
|
||||
)
|
||||
val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey, r.callTs)
|
||||
chatModel.callManager.reportNewIncomingCall(invitation)
|
||||
}
|
||||
is CR.CallOffer -> {
|
||||
// TODO askConfirmation?
|
||||
// TODO check encryption is compatible
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
|
||||
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey)
|
||||
val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
|
||||
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, relay = useRelay)
|
||||
}
|
||||
}
|
||||
is CR.CallAnswer -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
|
||||
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
@@ -552,15 +591,17 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
val invitation = chatModel.callInvitations.remove(r.contact.id)
|
||||
if (invitation != null) {
|
||||
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||
}
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.value = WCallCommand.End
|
||||
withApi {
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.callCommand.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
}
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
@@ -576,6 +617,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun receiveFile(fileId: Long) {
|
||||
val chatItem = apiReceiveFile(fileId)
|
||||
if (chatItem != null) {
|
||||
chatItemSimpleUpdate(chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
|
||||
val cInfo = aChatItem.chatInfo
|
||||
val cItem = aChatItem.chatItem
|
||||
@@ -608,7 +656,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
if (!getBackgroundServiceNoticeShown()) {
|
||||
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||
// the branch for the new users who has never seen service notice
|
||||
if (isIgnoringBatteryOptimizations(appContext)) {
|
||||
showBGServiceNotice()
|
||||
@@ -616,20 +664,20 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
showBGServiceNoticeIgnoreOptimization()
|
||||
}
|
||||
// set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
|
||||
setBackgroundServiceNoticeShown(true)
|
||||
setBackgroundServiceBatteryNoticeShown(true)
|
||||
} else if (!isIgnoringBatteryOptimizations(appContext) && getRunServiceInBackground()) {
|
||||
appPrefs.backgroundServiceNoticeShown.set(true)
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
} else if (!isIgnoringBatteryOptimizations(appContext) && appPrefs.runServiceInBackground.get()) {
|
||||
// the branch for users who have app installed, and have seen the service notice,
|
||||
// but the battery optimization for the app is on (Android 12) AND the service is running
|
||||
if (getBackgroundServiceBatteryNoticeShown()) {
|
||||
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
|
||||
showDisablingServiceNotice()
|
||||
setRunServiceInBackground(false)
|
||||
appPrefs.runServiceInBackground.set(false)
|
||||
chatModel.runServiceInBackground.value = false
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization()
|
||||
setBackgroundServiceBatteryNoticeShown(true)
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,33 +766,48 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
)
|
||||
}
|
||||
|
||||
fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||
|
||||
fun setAutoRestartWorkerVersion(version: Int) =
|
||||
sharedPreferences.edit()
|
||||
.putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
|
||||
.apply()
|
||||
|
||||
fun getRunServiceInBackground(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
||||
|
||||
fun setRunServiceInBackground(runService: Boolean) =
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService)
|
||||
.apply()
|
||||
|
||||
private fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
|
||||
fun setBackgroundServiceNoticeShown(shown: Boolean) =
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, shown)
|
||||
.apply()
|
||||
|
||||
private fun getBackgroundServiceBatteryNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
|
||||
|
||||
fun setBackgroundServiceBatteryNoticeShown(shown: Boolean) =
|
||||
sharedPreferences.edit()
|
||||
.putBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, shown)
|
||||
.apply()
|
||||
fun showLANotice(activity: FragmentActivity) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!appPrefs.laNoticeShown.get()) {
|
||||
appPrefs.laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||
onConfirm = {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
activity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
chatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laErrorToast(appContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedToast(appContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
chatModel.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||
@@ -764,16 +827,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
context.startActivity(this)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
||||
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
|
||||
}
|
||||
}
|
||||
|
||||
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
|
||||
|
||||
// ChatCommand
|
||||
sealed class CC {
|
||||
class Console(val cmd: String): CC()
|
||||
@@ -965,7 +1022,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null): CR()
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant): CR()
|
||||
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
|
||||
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
|
||||
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
|
||||
|
||||
@@ -8,13 +8,19 @@ val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
||||
val SimplexGreen = Color(98, 196, 103, 255)
|
||||
val SimplexGreen = Color(77, 218, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
val LightGray = Color(241, 242, 246, 255)
|
||||
val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(134, 135, 139, 255)
|
||||
val HighOrLowlight = Color(139, 135, 134, 255)
|
||||
val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
val WarningOrange = Color(255, 149, 0, 255)
|
||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||
val GroupDark = Color(80, 80, 80, 60)
|
||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||
val WarningOrange = Color(255, 127, 0, 255)
|
||||
val FileLight = Color(183, 190, 199, 255)
|
||||
val FileDark = Color(101, 101, 106, 255)
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
@@ -13,10 +14,8 @@ private val DarkColorPalette = darkColors(
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
// onPrimary = Color.Black,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.White,
|
||||
// onSurface = Color.White,
|
||||
onBackground = Color(0xFFFFFBFA),
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
|
||||
@@ -30,7 +30,7 @@ val Typography = Typography(
|
||||
h3 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 19.sp
|
||||
fontSize = 18.5.sp
|
||||
),
|
||||
body1 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
|
||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState()) }
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
BackHandler(onBack = close)
|
||||
TerminalLayout(
|
||||
chatModel.terminalItems,
|
||||
@@ -35,7 +35,7 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
withApi {
|
||||
// show "in progress"
|
||||
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
|
||||
composeState.value = ComposeState()
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
// hide "in progress"
|
||||
}
|
||||
},
|
||||
@@ -120,7 +120,7 @@ fun PreviewTerminalLayout() {
|
||||
SimpleXTheme {
|
||||
TerminalLayout(
|
||||
terminalItems = TerminalItem.sampleData,
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
|
||||
sendCommand = {},
|
||||
close = {}
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
if (enabled) {
|
||||
createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) }
|
||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.util.Log
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class CallManager(val chatModel: ChatModel) {
|
||||
fun reportNewIncomingCall(invitation: CallInvitation) {
|
||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||
with (chatModel) {
|
||||
callInvitations[invitation.contact.id] = invitation
|
||||
if (!chatModel.controller.appPrefs.experimentalCalls.get()) return
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: CallInvitation) {
|
||||
ModalManager.shared.closeModals()
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
} else {
|
||||
withApi {
|
||||
chatModel.switchingCall.value = true
|
||||
try {
|
||||
endCall(call = call)
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
} finally {
|
||||
withApi { chatModel.switchingCall.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun justAcceptIncomingCall(invitation: CallInvitation) {
|
||||
with (chatModel) {
|
||||
activeCall.value = Call(
|
||||
contact = invitation.contact,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.peerMedia,
|
||||
sharedKey = invitation.sharedKey
|
||||
)
|
||||
showCallView.value = true
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey, relay = useRelay)
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun endCall(call: Call) {
|
||||
with (chatModel) {
|
||||
if (call.callState == CallState.Ended) {
|
||||
Log.d(TAG, "CallManager.endCall: call ended")
|
||||
activeCall.value = null
|
||||
showCallView.value = false
|
||||
} else {
|
||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||
callCommand.value = WCallCommand.End
|
||||
showCallView.value = false
|
||||
controller.apiEndCall(call.contact)
|
||||
activeCall.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endCall(invitation: CallInvitation) {
|
||||
with (chatModel) {
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
withApi {
|
||||
if (!controller.apiRejectCall(invitation.contact)) {
|
||||
Log.e(TAG, "apiRejectCall error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportCallRemoteEnded(invitation: CallInvitation) {
|
||||
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.graphics.fonts.FontStyle
|
||||
import android.os.Build
|
||||
import android.service.controls.templates.ControlButton
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.magnifier
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
@@ -28,11 +20,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
@@ -41,8 +31,8 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ChatInfoLayout
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -50,14 +40,10 @@ import kotlinx.serialization.encodeToString
|
||||
|
||||
@Composable
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
val endCall = {
|
||||
Log.d(TAG, "ActiveCallView: endCall")
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.callCommand.value = null
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
BackHandler(onBack = endCall)
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
||||
@@ -94,16 +80,28 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
}
|
||||
is WCallResponse.Ended -> endCall()
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
withApi { chatModel.callManager.endCall(call) }
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
is WCallCommand.Answer ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
is WCallCommand.End -> endCall()
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
chatModel.showCallView.value = false
|
||||
else -> {}
|
||||
}
|
||||
is WCallResponse.Error -> {
|
||||
@@ -113,21 +111,15 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel, endCall)
|
||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, endCall: () -> Unit) {
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
dismiss = {
|
||||
chatModel.callCommand.value = WCallCommand.End
|
||||
withApi {
|
||||
chatModel.controller.apiEndCall(call.contact)
|
||||
endCall()
|
||||
}
|
||||
},
|
||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
@@ -153,10 +145,11 @@ private fun ActiveCallOverlayLayout(
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
||||
if (call.videoEnabled) {
|
||||
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
||||
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +201,7 @@ private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
||||
Text(text, color = Color(0xFFFFFFD8), style = style)
|
||||
Column(horizontalAlignment = alignment) {
|
||||
@@ -273,8 +266,6 @@ private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
//}
|
||||
|
||||
@Composable
|
||||
// for debugging
|
||||
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
@@ -359,8 +350,6 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("NEED PERMISSIONS")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.onboarding.SimpleXLogo
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IncomingCallActivity: ComponentActivity() {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val activity = this
|
||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
lockAfterIncomingCall()
|
||||
}
|
||||
|
||||
private fun unlockForIncomingCall() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
window.addFlags(activityFlags)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(this).requestDismissKeyguard(this, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockAfterIncomingCall() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
window.clearFlags(activityFlags)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
|
||||
}
|
||||
}
|
||||
|
||||
fun getKeyguardManager(context: Context): KeyguardManager =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
@Composable
|
||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
val switchingCall = m.switchingCall.value
|
||||
val invitation = m.activeCallInvitation.value
|
||||
val call = m.activeCall.value
|
||||
val showCallView = m.showCallView.value
|
||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView(m)
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
SoundPlayer.shared.stop()
|
||||
var intent = Intent(activity, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
activity.startActivity(intent)
|
||||
activity.finish()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlertLayout(
|
||||
invitation: CallInvitation,
|
||||
callOnLockScreen: CallOnLockScreen?,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit,
|
||||
openApp: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(30.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||
}
|
||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||
SimpleXLogo()
|
||||
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 50.dp)
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IconButton(action) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true
|
||||
)
|
||||
@Composable
|
||||
fun PreviewIncomingCallLockScreenAlert() {
|
||||
SimpleXTheme(true) {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation = CallInvitation(
|
||||
contact = Contact.sampleData,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
callOnLockScreen = null,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {},
|
||||
openApp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallAlertLayout(
|
||||
invitation,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallAlertLayout(
|
||||
invitation: CallInvitation,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallInfo(invitation: CallInvitation) {
|
||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||
Row {
|
||||
if (invitation.peerMedia == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.clickable(onClick = action)
|
||||
.defaultMinSize(minWidth = 50.dp)
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewIncomingCallAlertLayout() {
|
||||
SimpleXTheme {
|
||||
IncomingCallAlertLayout(
|
||||
invitation = CallInvitation(
|
||||
contact = Contact.sampleData,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaPlayer
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.withScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class SoundPlayer {
|
||||
var player: MediaPlayer? = null
|
||||
var playing = false
|
||||
|
||||
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
|
||||
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
playing = true
|
||||
withScope(scope) {
|
||||
while (playing) {
|
||||
if (sound) player?.start()
|
||||
vibrator?.vibrate(effect)
|
||||
delay(3500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playing = false
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = SoundPlayer()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -26,7 +27,7 @@ data class Call(
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
|
||||
CallState.InvitationReceived -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
@@ -36,20 +37,24 @@ data class Call(
|
||||
enum class CallState {
|
||||
WaitCapabilities,
|
||||
InvitationSent,
|
||||
InvitationReceived,
|
||||
InvitationAccepted,
|
||||
OfferSent,
|
||||
OfferReceived,
|
||||
AnswerReceived,
|
||||
Negotiated,
|
||||
Connected;
|
||||
Connected,
|
||||
Ended;
|
||||
|
||||
val text: String @Composable get() = when(this) {
|
||||
WaitCapabilities -> stringResource(R.string.callstate_starting)
|
||||
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
|
||||
InvitationReceived -> stringResource(R.string.callstate_starting)
|
||||
InvitationAccepted -> stringResource(R.string.callstate_starting)
|
||||
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(R.string.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(R.string.callstate_connecting)
|
||||
Connected -> stringResource(R.string.callstate_connected)
|
||||
Ended -> stringResource(R.string.callstate_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +90,7 @@ sealed class WCallResponse {
|
||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable class CallInvitation(val peerMedia: CallMediaType, val sharedKey: String?) {
|
||||
@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) {
|
||||
val callTypeText: String get() = generalGetString(when(peerMedia) {
|
||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
@@ -96,7 +101,7 @@ sealed class WCallResponse {
|
||||
})
|
||||
}
|
||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable class ConnectionInfo(val localCandidate: RTCIceCandidate?, val remoteCandidate: RTCIceCandidate) {
|
||||
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() = when {
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
|
||||
@@ -44,7 +44,9 @@ import kotlinx.datetime.Clock
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
val user = chatModel.currentUser.value
|
||||
val composeState = remember { mutableStateOf(ComposeState()) }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val enableCalls = chatModel.controller.appPrefs.experimentalCalls.get()
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -83,6 +85,8 @@ fun ChatView(chatModel: ChatModel) {
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
enableCalls = enableCalls,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||
openDirectChat = { contactId ->
|
||||
@@ -104,14 +108,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
receiveFile = { fileId ->
|
||||
withApi {
|
||||
val chatItem = chatModel.controller.apiReceiveFile(fileId)
|
||||
if (chatItem != null) {
|
||||
val cInfo = chatItem.chatInfo
|
||||
val cItem = chatItem.chatItem
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
withApi { chatModel.controller.receiveFile(fileId) }
|
||||
},
|
||||
startCall = { media ->
|
||||
val cInfo = chat.chatInfo
|
||||
@@ -126,15 +123,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||
} else {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.activeCall.value = Call(
|
||||
contact = contact,
|
||||
callState = CallState.InvitationReceived,
|
||||
localMedia = invitation.peerMedia,
|
||||
sharedKey = invitation.sharedKey
|
||||
)
|
||||
chatModel.showCallView.value = true
|
||||
chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey)
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -151,6 +140,8 @@ fun ChatLayout(
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
useLinkPreviews: Boolean,
|
||||
enableCalls: Boolean = false,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
@@ -178,12 +169,12 @@ fun ChatLayout(
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
|
||||
topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile, acceptCall)
|
||||
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +183,7 @@ fun ChatLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||
fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||
IconButton(onClick, modifier = modifier) {
|
||||
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||
@@ -209,18 +200,18 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||
// if (cInfo is ChatInfo.Direct) {
|
||||
// Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
// Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
// toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
// startCall(CallMediaType.Audio)
|
||||
// }
|
||||
// }
|
||||
// toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
// startCall(CallMediaType.Video)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if (cInfo is ChatInfo.Direct && enableCalls) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
startCall(CallMediaType.Audio)
|
||||
}
|
||||
}
|
||||
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
startCall(CallMediaType.Video)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 80.dp)
|
||||
@@ -269,6 +260,7 @@ fun ChatItemsList(
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chatItems: List<ChatItem>,
|
||||
useLinkPreviews: Boolean,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -310,11 +302,11 @@ fun ChatItemsList(
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -325,7 +317,7 @@ fun ChatItemsList(
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,12 +375,13 @@ fun PreviewChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
@@ -429,12 +422,13 @@ fun PreviewGroupChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
|
||||
@@ -24,7 +24,7 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -40,12 +40,13 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
|
||||
sealed class ComposePreview {
|
||||
object NoPreview: ComposePreview()
|
||||
class CLinkPreview(val linkPreview: LinkPreview): ComposePreview()
|
||||
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
class ImagePreview(val image: String): ComposePreview()
|
||||
class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
@@ -60,12 +61,14 @@ data class ComposeState(
|
||||
val message: String = "",
|
||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||
val inProgress: Boolean = false
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem): this(
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
|
||||
editingItem.content.text,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem)
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
useLinkPreviews = useLinkPreviews
|
||||
)
|
||||
|
||||
val editing: Boolean
|
||||
@@ -88,7 +91,7 @@ data class ComposeState(
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> true
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
val linkPreview: LinkPreview?
|
||||
get() =
|
||||
@@ -124,6 +127,7 @@ fun ComposeView(
|
||||
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = remember { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
@@ -239,6 +243,7 @@ fun ComposeView(
|
||||
|
||||
fun loadLinkPreview(url: String, wait: Long? = null) {
|
||||
if (pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
|
||||
withApi {
|
||||
if (wait != null) delay(wait)
|
||||
val lp = getLinkPreview(url)
|
||||
@@ -277,7 +282,7 @@ fun ComposeView(
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(cs.message)
|
||||
val lp = composePreview.linkPreview
|
||||
if (url == lp.uri) {
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(cs.message, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(cs.message)
|
||||
@@ -299,7 +304,7 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState()
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenImage.value = null
|
||||
chosenFile.value = null
|
||||
@@ -397,6 +402,7 @@ fun ComposeView(
|
||||
if (uri != null) {
|
||||
cancelledLinks.add(uri)
|
||||
}
|
||||
pendingLinkUrl.value = null
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ fun PreviewSendMsgView() {
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -129,7 +129,7 @@ fun PreviewSendMsgView() {
|
||||
fun PreviewSendMsgViewEditing() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData())
|
||||
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
@@ -150,7 +150,7 @@ fun PreviewSendMsgViewEditing() {
|
||||
fun PreviewSendMsgViewInProgress() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true)
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
|
||||
+3
-4
@@ -15,8 +15,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||
@@ -33,10 +32,10 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
AcceptCallButton(cInfo, acceptCall)
|
||||
}
|
||||
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = HighOrLowlight)
|
||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = Color.Green)
|
||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||
CICallStatus.Ended -> Row {
|
||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(status.duration(duration), color = HighOrLowlight)
|
||||
@@ -26,7 +26,8 @@ import chat.simplex.app.views.helpers.*
|
||||
fun CIImageView(
|
||||
image: String,
|
||||
file: CIFile?,
|
||||
showMenu: MutableState<Boolean>
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
@Composable
|
||||
fun loadingIndicator() {
|
||||
@@ -98,11 +99,21 @@ fun CIImageView(
|
||||
})
|
||||
} else {
|
||||
imageView(base64ToBitmap(image), onClick = {
|
||||
if (file != null && file.fileStatus == CIFileStatus.RcvAccepted)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
receiveFile(file.fileId)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
loadingIndicator()
|
||||
|
||||
@@ -36,6 +36,7 @@ fun ChatItemView(
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
useLinkPreviews: Boolean,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
@@ -69,7 +70,7 @@ fun ChatItemView(
|
||||
) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
@@ -98,7 +99,7 @@ fun ChatItemView(
|
||||
}
|
||||
if (cItem.meta.editable) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem)
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
@@ -144,6 +145,7 @@ fun ChatItemView(
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +204,8 @@ fun PreviewChatItemView() {
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
@@ -219,7 +222,8 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
|
||||
@@ -121,7 +121,7 @@ fun FramedItemView(
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, showMenu)
|
||||
CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile)
|
||||
if (mc.text == "") {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
}),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewView() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -11,17 +11,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.WarningOrange
|
||||
import chat.simplex.app.views.chat.clearChatDialog
|
||||
import chat.simplex.app.views.chat.deleteContactDialog
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -30,10 +30,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
var showMarkRead by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
|
||||
showMenu.value = false
|
||||
launch {
|
||||
delay(500L)
|
||||
showMarkRead = chat.chatStats.unreadCount > 0
|
||||
}
|
||||
delay(500L)
|
||||
showMarkRead = chat.chatStats.unreadCount > 0
|
||||
}
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
@@ -103,7 +101,8 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
},
|
||||
color = WarningOrange
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
@@ -135,7 +134,8 @@ fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bool
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
},
|
||||
color = WarningOrange
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -19,7 +20,6 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.MakeConnection
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
@@ -64,16 +64,14 @@ fun scaffoldController(): ScaffoldController {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel) {
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val scaffoldCtrl = scaffoldController()
|
||||
if (chatModel.clearOverlays.value) {
|
||||
scaffoldCtrl.collapse()
|
||||
ModalManager.shared.closeModal()
|
||||
chatModel.clearOverlays.value = false
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||
}
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel) },
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
@@ -104,43 +102,6 @@ fun ChatListView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val welcomeMsg = if (displayName != null) {
|
||||
String.format(stringResource(R.string.personal_welcome), displayName)
|
||||
} else stringResource(R.string.welcome)
|
||||
Text(
|
||||
text = welcomeMsg,
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
ChatHelpView { scaffoldCtrl.toggleSheet() }
|
||||
Row(
|
||||
Modifier.padding(top = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.this_text_is_available_in_settings),
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
||||
Row(
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
@@ -49,7 +50,8 @@ fun ChatPreviewView(chat: Chat) {
|
||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -14,7 +15,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.PendingContactConnection
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
|
||||
@Composable
|
||||
@@ -36,7 +37,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Text(contactConnection.description, maxLines = 2, color = HighOrLowlight)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Column(
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -12,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
@@ -32,7 +33,7 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||
Column(
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -34,14 +35,15 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
|
||||
fun ProfileImage(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
icon: ImageVector = Icons.Filled.AccountCircle
|
||||
icon: ImageVector = Icons.Filled.AccountCircle,
|
||||
color: Color = MaterialTheme.colors.secondary
|
||||
) {
|
||||
Box(Modifier.size(size)) {
|
||||
if (image == null) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
tint = color,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -18,7 +18,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@@ -66,23 +66,36 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
|
||||
|
||||
|
||||
@Composable
|
||||
fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) {
|
||||
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(R.string.image_descr_link_preview),
|
||||
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxWidth().weight(1F)) {
|
||||
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body2
|
||||
if (linkPreview == null) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(R.string.image_descr_link_preview),
|
||||
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxWidth().weight(1F)) {
|
||||
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
@@ -139,4 +152,12 @@ fun PreviewComposeLinkView() {
|
||||
SimpleXTheme {
|
||||
ComposeLinkView(LinkPreview.sampleData) { -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewComposeLinkViewLoading() {
|
||||
SimpleXTheme {
|
||||
ComposeLinkView(null) { -> }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
|
||||
sealed class LAResult {
|
||||
object Success: LAResult()
|
||||
class Error(val errString: CharSequence): LAResult()
|
||||
object Failed: LAResult()
|
||||
object Unavailable: LAResult()
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else ->
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithBiometricManager(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit,
|
||||
authenticators: Int
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
completed(LAResult.Error(errString))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
completed(LAResult.Success)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed)
|
||||
}
|
||||
}
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.setSubtitle(promptSubtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> {
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_simplex_lock_turned_on),
|
||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
|
||||
context,
|
||||
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laFailedToast(context: Context) = Toast.makeText(
|
||||
context,
|
||||
generalGetString(R.string.auth_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
)
|
||||
|
||||
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
|
||||
)
|
||||
@@ -9,20 +9,22 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.TAG
|
||||
|
||||
@Composable
|
||||
fun ModalView(close: () -> Unit, content: @Composable () -> Unit) {
|
||||
fun ModalView(
|
||||
close: () -> Unit,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(background)) {
|
||||
CloseSheetBar(close)
|
||||
Box(Modifier.padding(horizontal = 16.dp)) { content() }
|
||||
Box(modifier) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +34,7 @@ class ModalManager {
|
||||
private val modalCount = mutableStateOf(0)
|
||||
|
||||
fun showModal(content: @Composable () -> Unit) {
|
||||
showCustomModal { close -> ModalView(close, content) }
|
||||
showCustomModal { close -> ModalView(close, content = content) }
|
||||
}
|
||||
|
||||
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
@@ -48,6 +50,10 @@ class ModalManager {
|
||||
modalCount.value = modalViews.count()
|
||||
}
|
||||
|
||||
fun closeModals() {
|
||||
while (modalViews.isNotEmpty()) closeModal()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
||||
|
||||
@@ -34,8 +34,10 @@ import java.util.*
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch { withContext(Dispatchers.Main, action) }
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
|
||||
|
||||
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
scope.launch { withContext(Dispatchers.Main, action) }
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened, Closed
|
||||
|
||||
@@ -40,13 +40,7 @@ fun SimpleXInfoLayout(
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
SimpleXLogo()
|
||||
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
|
||||
|
||||
@@ -79,6 +73,17 @@ fun SimpleXInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun CallSettingsView(m: ChatModel) {
|
||||
CallSettingsLayout(
|
||||
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
|
||||
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallSettingsLayout(
|
||||
webrtcPolicyRelay: Preference<Boolean>,
|
||||
callOnLockScreen: Preference<CallOnLockScreen>,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
Text(
|
||||
stringResource(R.string.your_calls),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
Box(Modifier.padding(start = 10.dp)) {
|
||||
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||
}
|
||||
divider()
|
||||
|
||||
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
|
||||
Text(stringResource(R.string.call_on_lock_screen))
|
||||
Row {
|
||||
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPreferenceToggle(
|
||||
text: String,
|
||||
preference: Preference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 24.dp))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
preference.set(it)
|
||||
prefState.value = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text)
|
||||
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
||||
RadioButton(selected = prefState.value == value, colors = colors, onClick = {
|
||||
preference.set(value)
|
||||
prefState.value = value
|
||||
})
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_experimental_features),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.TravelExplore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_privacy),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
}
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
divider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
-149
@@ -1,8 +1,7 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -10,39 +9,50 @@ import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel) {
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
|
||||
fun setRunServiceInBackground(on: Boolean) {
|
||||
chatModel.controller.appPrefs.runServiceInBackground.set(on)
|
||||
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.runServiceInBackground.value = on
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
runServiceInBackground = chatModel.runServiceInBackground,
|
||||
setRunServiceInBackground = { on ->
|
||||
chatModel.controller.setRunServiceInBackground(on)
|
||||
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.setBackgroundServiceNoticeShown(false)
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.runServiceInBackground.value = on
|
||||
},
|
||||
setRunServiceInBackground = ::setRunServiceInBackground,
|
||||
setPerformLA = setPerformLA,
|
||||
enableCalls = remember { mutableStateOf(chatModel.controller.appPrefs.experimentalCalls.get()) },
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
modalView(chatModel)
|
||||
}
|
||||
} } },
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
|
||||
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
|
||||
@@ -58,165 +68,194 @@ fun SettingsLayout(
|
||||
profile: Profile,
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
enableCalls: MutableState<Boolean>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showTerminal: () -> Unit,
|
||||
// showVideoChatPrototype: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(8.dp)
|
||||
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
|
||||
Text(
|
||||
stringResource(R.string.your_settings),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
|
||||
ProfileImage(size = 60.dp, profile.image)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profile.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(profile.fullName)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
|
||||
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
|
||||
ProfilePreview(profile)
|
||||
}
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) })
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showModal { UserAddressView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.icon_descr_address),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.your_simplex_contact_address))
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(showModal { HelpView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.HelpOutline,
|
||||
contentDescription = stringResource(R.string.icon_descr_help),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.how_to_use_simplex_chat))
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
if (enableCalls.value) {
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) })
|
||||
divider()
|
||||
}
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) })
|
||||
divider()
|
||||
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
contentDescription = stringResource(R.string.icon_descr_help),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.about_simplex_chat))
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showModal { MarkdownHelpView() }) {
|
||||
Icon(
|
||||
Icons.Outlined.TextFormat,
|
||||
contentDescription = stringResource(R.string.markdown_help),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.markdown_in_messages))
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Tag,
|
||||
contentDescription = stringResource(R.string.icon_descr_simplex_team),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.chat_with_the_founder),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Email,
|
||||
contentDescription = stringResource(R.string.icon_descr_email),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.send_us_an_email),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(showModal { SMPServersView(it) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Dns,
|
||||
contentDescription = stringResource(R.string.smp_servers),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.smp_servers))
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView() {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(R.string.private_notifications),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.private_notifications), Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F))
|
||||
Switch(
|
||||
checked = runServiceInBackground.value,
|
||||
onCheckedChange = { setRunServiceInBackground(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = stringResource(R.string.chat_console),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.chat_console))
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
// SettingsSectionView(showVideoChatPrototype) {
|
||||
SettingsSectionView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
ChatConsoleItem(showTerminal)
|
||||
divider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
divider()
|
||||
AppVersionItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
|
||||
Column {
|
||||
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp)
|
||||
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(horizontal = 6.dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun PrivateNotificationsItem(
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit
|
||||
) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(R.string.private_notifications),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.private_notifications),
|
||||
Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = runServiceInBackground.value,
|
||||
onCheckedChange = { setRunServiceInBackground(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Lock,
|
||||
contentDescription = stringResource(R.string.chat_lock),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.chat_lock), Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
)
|
||||
Switch(
|
||||
checked = performLA.value,
|
||||
onCheckedChange = { setPerformLA(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SettingsItemView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = stringResource(R.string.chat_console),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.chat_console))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem() {
|
||||
SettingsItemView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) {
|
||||
ProfileImage(size = size, image = profileOf.image, color = color)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profileOf.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
|
||||
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.fillMaxWidth()
|
||||
@@ -229,6 +268,26 @@ fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) {
|
||||
SettingsItemView(click) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(text, color = textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggle(text, pref, prefState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -242,8 +301,11 @@ fun PreviewSettingsLayout() {
|
||||
profile = Profile.sampleData,
|
||||
runServiceInBackground = remember { mutableStateOf(true) },
|
||||
setRunServiceInBackground = {},
|
||||
showModal = {{}},
|
||||
showCustomModal = {{}},
|
||||
setPerformLA = {},
|
||||
enableCalls = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
showTerminal = {},
|
||||
// showVideoChatPrototype = {}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
@@ -46,13 +46,13 @@
|
||||
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
|
||||
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
|
||||
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||
<string name="error_deleting_contact">Ошибка удаления контакта</string>
|
||||
<string name="error_deleting_group">Ошибка удаления группы</string>
|
||||
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
||||
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
|
||||
<string name="private_instant_notifications_disabled">Приватные уведомления выключены!</string>
|
||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
|
||||
@@ -64,6 +64,27 @@
|
||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
|
||||
<string name="simplex_service_notification_text">Приём сообщений…</string>
|
||||
|
||||
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
|
||||
<string name="la_notice_turn_on">Включить</string>
|
||||
|
||||
<!-- LocalAuthentication.kt -->
|
||||
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
|
||||
<string name="auth_unlock">Разблокировать</string>
|
||||
<string name="auth_log_in_using_credential">Пройдите аутентификацию</string>
|
||||
<string name="auth_enable_simplex_lock">Включить блокировку SimpleX</string>
|
||||
<string name="auth_disable_simplex_lock">Отключить блокировку SimpleX</string>
|
||||
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
|
||||
<string name="auth_error">Ошибка аутентификации</string>
|
||||
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||
<string name="auth_failed">Ошибка аутентификации</string>
|
||||
<string name="auth_unavailable">Аутентификация недоступна</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
|
||||
<string name="auth_retry">Повторить</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Ответить</string>
|
||||
<string name="share_verb">Поделиться</string>
|
||||
@@ -147,7 +168,7 @@
|
||||
<string name="toast_permission_denied">Разрешение не получено!</string>
|
||||
<string name="use_camera_button">Камера</string>
|
||||
<string name="from_gallery_button">Галерея</string>
|
||||
<string name="choose_file">Файлы\n(v2.0)</string>
|
||||
<string name="choose_file">Файлы</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
@@ -219,8 +240,7 @@
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Настройки</string>
|
||||
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
|
||||
@@ -231,6 +251,7 @@
|
||||
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
|
||||
<string name="send_us_an_email">Отправить email</string>
|
||||
<string name="private_notifications">Приватные уведомления</string>
|
||||
<string name="chat_lock">Блокировка SimpleX</string>
|
||||
<string name="chat_console">Консоль</string>
|
||||
<string name="smp_servers">SMP серверы</string>
|
||||
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>
|
||||
@@ -294,18 +315,20 @@
|
||||
<string name="callstatus_missed">пропущенный звонок</string>
|
||||
<string name="callstatus_rejected">отклоненный звонок</string>
|
||||
<string name="callstatus_accepted">принятый звонок</string>
|
||||
<string name="callstatus_connecting">соединяется…</string>
|
||||
<string name="callstatus_connecting">звонок соединяется…</string>
|
||||
<string name="callstatus_in_progress">активный звонок</string>
|
||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">ошибка соединения</string>
|
||||
<string name="callstatus_error">ошибка звонка</string>
|
||||
|
||||
<!-- CallState -->
|
||||
<string name="callstate_starting">инициализация…</string>
|
||||
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
|
||||
<string name="callstate_waiting_for_confirmation">ожидается подтверждение…</string>
|
||||
<string name="callstate_received_answer">получен ответ…</string>
|
||||
<string name="callstate_received_confirmation">получено подтверждение…</string>
|
||||
<string name="callstate_connecting">соединяется…</string>
|
||||
<string name="callstate_connected">соединено</string>
|
||||
<string name="callstate_ended">завершен</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
|
||||
@@ -348,11 +371,27 @@
|
||||
<string name="encrypted_video_call">e2e зашифрованный видеозвонок</string>
|
||||
<string name="audio_call_no_encryption">аудиозвонок (не e2e зашифрованный)</string>
|
||||
<string name="encrypted_audio_call">e2e зашифрованный аудиозвонок</string>
|
||||
<string name="answer">Принять</string>
|
||||
<string name="accept">Принять</string>
|
||||
<string name="reject">Отклонить</string>
|
||||
<string name="ignore">Закрыть</string>
|
||||
<string name="call_already_ended">Звонок уже завершен!</string>
|
||||
<string name="icon_descr_video_call">видеозвонок</string>
|
||||
<string name="icon_descr_audio_call">аудиозвонок</string>
|
||||
|
||||
<!-- Call settings -->
|
||||
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
|
||||
<string name="your_calls">Ваши звонки</string>
|
||||
<string name="connect_calls_via_relay">Соединяться через сервер (relay)</string>
|
||||
<string name="call_on_lock_screen">Звонки на экране блокировки:</string>
|
||||
<string name="accept_call_on_lock_screen">Принимать</string>
|
||||
<string name="show_call_on_lock_screen">Показывать</string>
|
||||
<string name="no_call_on_lock_screen">Выключить</string>
|
||||
|
||||
<!-- Call Lock Screen -->
|
||||
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
|
||||
<string name="open_verb">Открыть</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e зашифровано</string>
|
||||
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
|
||||
@@ -375,4 +414,27 @@
|
||||
<string name="icon_descr_call_progress">Текущий звонок</string>
|
||||
<string name="icon_descr_call_ended">Звонок завершен</string>
|
||||
<string name="answer_call">Принять звонок</string>
|
||||
|
||||
<!-- Message integrity -->
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений"</string>
|
||||
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
|
||||
<string name="integrity_msg_bad_id">ошибка ID сообщения</string>
|
||||
<string name="integrity_msg_duplicate">повторное сообщение</string>
|
||||
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
|
||||
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Конфиденциальность</string>
|
||||
<string name="your_privacy">Конфиденциальность</string>
|
||||
<string name="auto_accept_images">Автоприем изображений</string>
|
||||
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">ВЫ</string>
|
||||
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
|
||||
<string name="settings_section_title_help">ПОМОЩЬ</string>
|
||||
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
|
||||
<string name="settings_section_title_device">УСТРОЙСТВО</string>
|
||||
<string name="settings_section_title_chats">ЧАТЫ</string>
|
||||
<string name="settings_experimental_features">Экспериментальные функции</string>
|
||||
</resources>
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
|
||||
<string name="cannot_delete_contact">Can\'t delete contact!</string>
|
||||
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||
<string name="error_deleting_contact">Error deleting contact</string>
|
||||
<string name="error_deleting_group">Error deleting group</string>
|
||||
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
||||
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||
<string name="private_instant_notifications">Private instant notifications!</string>
|
||||
<string name="private_instant_notifications_disabled">Private notifications disabled!</string>
|
||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> – it uses a few percent of the battery per day.</string>
|
||||
@@ -64,6 +64,27 @@
|
||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
|
||||
<string name="simplex_service_notification_text">Receiving messages…</string>
|
||||
|
||||
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled.</string>
|
||||
<string name="la_notice_turn_on">Turn on</string>
|
||||
|
||||
<!-- LocalAuthentication.kt -->
|
||||
<string name="auth_simplex_lock_turned_on">SimpleX Lock turned on</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">You will be required to authenticate when you start or resume the app after 30 seconds in background.</string>
|
||||
<string name="auth_unlock">Unlock</string>
|
||||
<string name="auth_log_in_using_credential">Log in using your credential</string>
|
||||
<string name="auth_enable_simplex_lock">Enable SimpleX Lock</string>
|
||||
<string name="auth_disable_simplex_lock">Disable SimpleX Lock</string>
|
||||
<string name="auth_confirm_credential">Confirm your credential</string>
|
||||
<string name="auth_error">Authentication error</string>
|
||||
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||
<string name="auth_failed">Authentication failed</string>
|
||||
<string name="auth_unavailable">Authentication unavailable</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
|
||||
<string name="auth_retry">Retry</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Reply</string>
|
||||
<string name="share_verb">Share</string>
|
||||
@@ -148,7 +169,7 @@
|
||||
<string name="toast_permission_denied">Permission Denied!</string>
|
||||
<string name="use_camera_button">Use Camera</string>
|
||||
<string name="from_gallery_button">From Gallery</string>
|
||||
<string name="choose_file">Choose file\n(new in v2.0)</string>
|
||||
<string name="choose_file">Choose file</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
@@ -236,6 +257,7 @@
|
||||
<string name="chat_with_the_founder">Connect to the developers</string>
|
||||
<string name="send_us_an_email">Send us email</string>
|
||||
<string name="private_notifications">Private notifications</string>
|
||||
<string name="chat_lock">SimpleX Lock</string>
|
||||
<string name="chat_console">Chat console</string>
|
||||
<string name="smp_servers">SMP servers</string>
|
||||
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>
|
||||
@@ -290,23 +312,25 @@
|
||||
<string name="colored">colored</string>
|
||||
<string name="secret">secret</string>
|
||||
|
||||
<!-- CICallStatus -->
|
||||
<!-- CICallStatus - in chat items -->
|
||||
<string name="callstatus_calling">calling…</string>
|
||||
<string name="callstatus_missed">missed</string>
|
||||
<string name="callstatus_rejected">rejected</string>
|
||||
<string name="callstatus_accepted">accepted</string>
|
||||
<string name="callstatus_connecting">connecting…</string>
|
||||
<string name="callstatus_in_progress">in progress</string>
|
||||
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">error</string>
|
||||
<string name="callstatus_missed">missed call</string>
|
||||
<string name="callstatus_rejected">rejected call</string>
|
||||
<string name="callstatus_accepted">accepted call</string>
|
||||
<string name="callstatus_connecting">connecting call…</string>
|
||||
<string name="callstatus_in_progress">call in progress</string>
|
||||
<string name="callstatus_ended">call ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">call error</string>
|
||||
|
||||
<!-- CallState -->
|
||||
<string name="callstate_starting">starting…</string>
|
||||
<string name="callstate_waiting_for_answer">waiting for answer…</string>
|
||||
<string name="callstate_waiting_for_confirmation">waiting for confirmation…</string>
|
||||
<string name="callstate_received_answer">received answer…</string>
|
||||
<string name="callstate_received_confirmation">received confirmation…</string>
|
||||
<string name="callstate_connecting">connecting…</string>
|
||||
<string name="callstate_connected">connected</string>
|
||||
<string name="callstate_ended">ended</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
||||
@@ -349,11 +373,27 @@
|
||||
<string name="encrypted_video_call">e2e encrypted video call</string>
|
||||
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
|
||||
<string name="encrypted_audio_call">e2e encrypted audio call</string>
|
||||
<string name="answer">Answer</string>
|
||||
<string name="accept">Accept</string>
|
||||
<string name="reject">Reject</string>
|
||||
<string name="ignore">Ignore</string>
|
||||
<string name="call_already_ended">Call already ended!</string>
|
||||
<string name="icon_descr_video_call">video call</string>
|
||||
<string name="icon_descr_audio_call">audio call</string>
|
||||
|
||||
<!-- Call settings -->
|
||||
<string name="settings_audio_video_calls">Audio & video calls</string>
|
||||
<string name="your_calls">Your calls</string>
|
||||
<string name="connect_calls_via_relay">Connect via relay</string>
|
||||
<string name="call_on_lock_screen">Calls on lock screen:</string>
|
||||
<string name="accept_call_on_lock_screen">Accept</string>
|
||||
<string name="show_call_on_lock_screen">Show</string>
|
||||
<string name="no_call_on_lock_screen">Disable</string>
|
||||
|
||||
<!-- Call Lock Screen -->
|
||||
<string name="open_simplex_chat_to_accept_call">Open <xliff:g id="appNameFull">SimpleX Chat</xliff:g> to accept call</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Enable calls from lock screen via Settings.</string>
|
||||
<string name="open_verb">Open</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e encrypted</string>
|
||||
<string name="status_no_e2e_encryption">no e2e encryption</string>
|
||||
@@ -376,4 +416,27 @@
|
||||
<string name="icon_descr_call_progress">Call in progress</string>
|
||||
<string name="icon_descr_call_ended">Call ended</string>
|
||||
<string name="answer_call">Answer call</string>
|
||||
|
||||
<!-- Message integrity -->
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> skipped message(s)"</string>
|
||||
<string name="integrity_msg_bad_hash">bad message hash</string>
|
||||
<string name="integrity_msg_bad_id">bad message ID</string>
|
||||
<string name="integrity_msg_duplicate">duplicate message</string>
|
||||
<string name="alert_title_skipped_messages">Skipped messages</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages.</string>
|
||||
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Privacy & security</string>
|
||||
<string name="your_privacy">Your privacy</string>
|
||||
<string name="auto_accept_images">Auto-accept images</string>
|
||||
<string name="send_link_previews">Send link previews</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">YOU</string>
|
||||
<string name="settings_section_title_settings">SETTINGS</string>
|
||||
<string name="settings_section_title_help">HELP</string>
|
||||
<string name="settings_section_title_develop">DEVELOP</string>
|
||||
<string name="settings_section_title_device">DEVICE</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
<string name="settings_experimental_features">Experimental features</string>
|
||||
</resources>
|
||||
|
||||
@@ -70,7 +70,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
// TODO check if app in background
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||
// TODO remove
|
||||
NtfManager.shared.notifyCheckingMessages()
|
||||
// NtfManager.shared.notifyCheckingMessages()
|
||||
receiveMessages(completionHandler)
|
||||
} else if let smpQueue = ntfData["checkMessage"] as? String {
|
||||
// TODO check if app in background
|
||||
|
||||
@@ -10,27 +10,117 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@State private var showNotificationAlert = false
|
||||
@ObservedObject var callController = CallController.shared
|
||||
@Binding var doAuthenticate: Bool
|
||||
@Binding var enteredBackground: Double?
|
||||
@State private var userAuthorized: Bool?
|
||||
@State private var laFailed: Bool = false
|
||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let step = chatModel.onboardingStage {
|
||||
if case .onboardingComplete = step,
|
||||
let user = chatModel.currentUser {
|
||||
ChatListView(user: user)
|
||||
.onAppear {
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
if userAuthorized == true {
|
||||
if let step = chatModel.onboardingStage {
|
||||
if case .onboardingComplete = step,
|
||||
let user = chatModel.currentUser {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(user: user)
|
||||
.onAppear {
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
if (!prefLANoticeShown && prefShowLANotice) {
|
||||
prefLANoticeShown = true
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
}
|
||||
prefShowLANotice = true
|
||||
}
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
ActiveCallView(call: call)
|
||||
}
|
||||
IncomingCallView()
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
} else if prefPerformLA && laFailed {
|
||||
retryAuthView()
|
||||
}
|
||||
}
|
||||
.onChange(of: doAuthenticate) { doAuth in
|
||||
if doAuth, authenticationExpired() {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
}
|
||||
|
||||
private func retryAuthView() -> some View {
|
||||
Button {
|
||||
laFailed = false
|
||||
runAuthenticate()
|
||||
} label: { Label("Retry", systemImage: "arrow.counterclockwise") }
|
||||
}
|
||||
|
||||
private func runAuthenticate() {
|
||||
if !prefPerformLA {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
chatModel.showChatInfo = false
|
||||
DispatchQueue.main.async() {
|
||||
userAuthorized = false
|
||||
authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
userAuthorized = true
|
||||
case .failed:
|
||||
laFailed = true
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
case .unavailable:
|
||||
userAuthorized = true
|
||||
prefPerformLA = false
|
||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticationExpired() -> Bool {
|
||||
if let enteredBackground = enteredBackground {
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func laNoticeAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("SimpleX Lock"),
|
||||
message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."),
|
||||
primaryButton: .default(Text("Turn on")) {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
prefPerformLA = true
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
case .failed:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laFailedAlert())
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laUnavailableInstructionAlert())
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func notificationAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Notifications are disabled!"),
|
||||
@@ -90,11 +180,15 @@ final class AlertManager: ObservableObject {
|
||||
}
|
||||
|
||||
func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) {
|
||||
if let message = message {
|
||||
showAlert(Alert(title: Text(title), message: Text(message)))
|
||||
} else {
|
||||
showAlert(Alert(title: Text(title)))
|
||||
}
|
||||
showAlert(mkAlert(title: title, message: message))
|
||||
}
|
||||
}
|
||||
|
||||
func mkAlert(title: LocalizedStringKey, message: LocalizedStringKey? = nil) -> Alert {
|
||||
if let message = message {
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
} else {
|
||||
return Alert(title: Text(title))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var currentUser: User?
|
||||
@Published var showChatInfo: Bool = false // TODO comprehensively close modal views on authentication
|
||||
// list of chat "previews"
|
||||
@Published var chats: [Chat] = []
|
||||
// current chat
|
||||
@@ -28,10 +30,10 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenStatus = NtfTknStatus.new
|
||||
// current WebRTC call
|
||||
@Published var callInvitations: Dictionary<ChatId, CallInvitation> = [:]
|
||||
@Published var activeCallInvitation: ContactRef?
|
||||
@Published var activeCall: Call?
|
||||
@Published var callCommand: WCallCommand?
|
||||
@Published var showCallView = false
|
||||
var callWebView: WKWebView?
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
|
||||
@@ -37,25 +37,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall),
|
||||
let chatId = content.userInfo["chatId"] as? String,
|
||||
case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo,
|
||||
let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
|
||||
let cc = CallController.shared
|
||||
if action == ntfActionAcceptCall {
|
||||
chatModel.activeCallInvitation = nil
|
||||
chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia)
|
||||
chatModel.showCallView = true
|
||||
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
|
||||
cc.answerCall(invitation: invitation)
|
||||
} else {
|
||||
Task {
|
||||
do {
|
||||
try await apiRejectCall(contact)
|
||||
if chatModel.activeCall?.contact.id == chatId {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.callCommand = .end
|
||||
chatModel.activeCall = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cc.endCall(invitation: invitation)
|
||||
}
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
@@ -89,6 +76,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
// this notification is deliverd from the notifications server
|
||||
// when the app is in foreground it does not need to be shown
|
||||
case ntfCategoryCheckMessage: return []
|
||||
case ntfCategoryCallInvitation: return []
|
||||
default: return [.sound, .banner, .list]
|
||||
}
|
||||
} else {
|
||||
@@ -136,11 +124,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: ntfActionAcceptCall,
|
||||
title: NSLocalizedString("Answer", comment: "accept incoming call via notification")
|
||||
title: NSLocalizedString("Accept", comment: "accept incoming call via notification"),
|
||||
options: .foreground
|
||||
),
|
||||
UNNotificationAction(
|
||||
identifier: ntfActionRejectCall,
|
||||
title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification")
|
||||
title: NSLocalizedString("Reject", comment: "reject incoming call via notification")
|
||||
)
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
@@ -194,9 +183,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
addNotification(createMessageReceivedNtf(cInfo, cItem))
|
||||
}
|
||||
|
||||
func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) {
|
||||
func notifyCallInvitation(_ invitation: CallInvitation) {
|
||||
logger.debug("NtfManager.notifyCallInvitation")
|
||||
addNotification(createCallInvitationNtf(contact, invitation))
|
||||
addNotification(createCallInvitationNtf(invitation))
|
||||
}
|
||||
|
||||
// TODO remove
|
||||
|
||||
@@ -199,7 +199,7 @@ enum ChatResponse: Decodable, Error {
|
||||
case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
|
||||
case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
|
||||
case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
|
||||
case callInvitation(contact: Contact, callType: CallType, sharedKey: String?)
|
||||
case callInvitation(contact: Contact, callType: CallType, sharedKey: String?, callTs: Date)
|
||||
case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool)
|
||||
case callAnswer(contact: Contact, answer: WebRTCSession)
|
||||
case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo)
|
||||
@@ -322,7 +322,7 @@ enum ChatResponse: Decodable, Error {
|
||||
case let .sndFileCancelled(chatItem, _): return String(describing: chatItem)
|
||||
case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem)
|
||||
case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem)
|
||||
case let .callInvitation(contact, callType, sharedKey): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")"
|
||||
case let .callInvitation(contact, callType, sharedKey, _): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")"
|
||||
case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))"
|
||||
case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))"
|
||||
case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))"
|
||||
|
||||
@@ -24,25 +24,25 @@ struct WebRTCExtraInfo: Codable {
|
||||
}
|
||||
|
||||
struct CallInvitation {
|
||||
var contact: Contact
|
||||
var callkitUUID: UUID?
|
||||
var peerMedia: CallMediaType
|
||||
var sharedKey: String?
|
||||
var callTs: Date
|
||||
var callTypeText: LocalizedStringKey {
|
||||
get {
|
||||
switch peerMedia {
|
||||
case .video: return sharedKey == nil ? "video call (not e2e encrypted)." : "**e2e encrypted** video call."
|
||||
case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)." : "**e2e encrypted** audio call."
|
||||
case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call"
|
||||
case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call"
|
||||
}
|
||||
}
|
||||
}
|
||||
var callTitle: LocalizedStringKey {
|
||||
get {
|
||||
switch peerMedia {
|
||||
case .video: return "Incoming video call"
|
||||
case .audio: return "Incoming audio call"
|
||||
}
|
||||
}
|
||||
}
|
||||
var encryptionText: LocalizedStringKey { get { sharedKey == nil ? "no e2e encryption" : "with e2e encryption" } }
|
||||
|
||||
static let sampleData = CallInvitation(
|
||||
contact: Contact.sampleData,
|
||||
peerMedia: .audio,
|
||||
callTs: .now
|
||||
)
|
||||
}
|
||||
|
||||
struct CallType: Codable {
|
||||
|
||||
@@ -520,6 +520,16 @@ struct ChatItem: Identifiable, Decodable {
|
||||
file: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", status, false, false, false),
|
||||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: fromMsgId, toMsgId: toMsgId)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CIDirection: Decodable {
|
||||
@@ -601,6 +611,7 @@ enum CIContent: Decodable, ItemContent {
|
||||
case rcvDeleted(deleteMode: CIDeleteMode)
|
||||
case sndCall(status: CICallStatus, duration: Int)
|
||||
case rcvCall(status: CICallStatus, duration: Int)
|
||||
case rcvIntegrityError(msgError: MsgErrorType)
|
||||
|
||||
var text: String {
|
||||
get {
|
||||
@@ -611,6 +622,7 @@ enum CIContent: Decodable, ItemContent {
|
||||
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
|
||||
case let .sndCall(status, duration): return status.text(duration)
|
||||
case let .rcvCall(status, duration): return status.text(duration)
|
||||
case let .rcvIntegrityError(msgError): return msgError.text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -879,13 +891,13 @@ enum CICallStatus: String, Decodable {
|
||||
func text(_ sec: Int) -> String {
|
||||
switch self {
|
||||
case .pending: return NSLocalizedString("calling…", comment: "call status")
|
||||
case .missed: return NSLocalizedString("missed", comment: "call status")
|
||||
case .rejected: return NSLocalizedString("rejected", comment: "call status")
|
||||
case .accepted: return NSLocalizedString("accepted", comment: "call status")
|
||||
case .negotiated: return NSLocalizedString("connecting…", comment: "call status")
|
||||
case .progress: return NSLocalizedString("in progress", comment: "call status")
|
||||
case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %@", comment: "call status"), CICallStatus.durationText(sec))
|
||||
case .error: return NSLocalizedString("error", comment: "call status")
|
||||
case .missed: return NSLocalizedString("missed call", comment: "call status")
|
||||
case .rejected: return NSLocalizedString("rejected call", comment: "call status")
|
||||
case .accepted: return NSLocalizedString("accepted call", comment: "call status")
|
||||
case .negotiated: return NSLocalizedString("connecting call…", comment: "call status")
|
||||
case .progress: return NSLocalizedString("call in progress", comment: "call status")
|
||||
case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec))
|
||||
case .error: return NSLocalizedString("call error", comment: "call status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,3 +905,20 @@ enum CICallStatus: String, Decodable {
|
||||
String(format: "%02d:%02d", sec / 60, sec % 60)
|
||||
}
|
||||
}
|
||||
|
||||
enum MsgErrorType: Decodable {
|
||||
case msgSkipped(fromMsgId: Int64, toMsgId: Int64)
|
||||
case msgBadId(msgId: Int64)
|
||||
case msgBadHash
|
||||
case msgDuplicate
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case let .msgSkipped(fromMsgId, toMsgId):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("%d skipped message(s)", comment: "integrity error chat item"), toMsgId - fromMsgId + 1)
|
||||
case .msgBadHash: return NSLocalizedString("bad message hash", comment: "integrity error chat item") // not used now
|
||||
case .msgBadId: return NSLocalizedString("bad message ID", comment: "integrity error chat item") // not used now
|
||||
case .msgDuplicate: return NSLocalizedString("duplicate message", comment: "integrity error chat item") // not used now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,16 +50,16 @@ func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutable
|
||||
)
|
||||
}
|
||||
|
||||
func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent {
|
||||
func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent {
|
||||
let text = invitation.peerMedia == .video
|
||||
? NSLocalizedString("Incoming video call", comment: "notification")
|
||||
: NSLocalizedString("Incoming audio call", comment: "notification")
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryCallInvitation,
|
||||
title: "\(contact.chatViewName):",
|
||||
title: "\(invitation.contact.chatViewName):",
|
||||
body: text,
|
||||
targetContentIdentifier: nil,
|
||||
userInfo: ["chatId": contact.id]
|
||||
userInfo: ["chatId": invitation.contact.id]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import UIKit
|
||||
import Dispatch
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
import CallKit
|
||||
|
||||
private var chatController: chat_ctrl?
|
||||
|
||||
@@ -577,7 +578,8 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if case .image = cItem.content.msgContent,
|
||||
let file = cItem.file,
|
||||
file.fileSize <= maxImageSize {
|
||||
file.fileSize <= maxImageSize,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
@@ -628,23 +630,44 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
case let .callInvitation(contact, callType, sharedKey):
|
||||
let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey)
|
||||
case let .callInvitation(contact, callType, sharedKey, callTs):
|
||||
let uuid = UUID()
|
||||
var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey, callTs: callTs)
|
||||
m.callInvitations[contact.id] = invitation
|
||||
if (m.activeCallInvitation == nil) {
|
||||
m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName)
|
||||
CallController.shared.reportNewIncomingCall(invitation: invitation) { error in
|
||||
if let error = error {
|
||||
invitation.callkitUUID = nil
|
||||
m.callInvitations[contact.id] = invitation
|
||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||
} else {
|
||||
logger.debug("reportNewIncomingCall success")
|
||||
}
|
||||
}
|
||||
NtfManager.shared.notifyCallInvitation(contact, invitation)
|
||||
|
||||
// This will be called from notification service extension
|
||||
// CXProvider.reportNewIncomingVoIPPushPayload([
|
||||
// "displayName": contact.displayName,
|
||||
// "contactId": contact.id,
|
||||
// "uuid": invitation.callkitUUID
|
||||
// ]) { error in
|
||||
// if let error = error {
|
||||
// logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription)")
|
||||
// } else {
|
||||
// logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)")
|
||||
// }
|
||||
// }
|
||||
case let .callOffer(contact, callType, offer, sharedKey, _):
|
||||
// TODO askConfirmation?
|
||||
// TODO check encryption is compatible
|
||||
withCall(contact) { call in
|
||||
m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey)
|
||||
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true)
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true, relay: useRelay)
|
||||
}
|
||||
case let .callAnswer(contact, answer):
|
||||
withCall(contact) { call in
|
||||
m.activeCall = call.copy(callState: .negotiated)
|
||||
call.callState = .answerReceived
|
||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||
}
|
||||
case let .callExtraInfo(contact, extraInfo):
|
||||
@@ -652,9 +675,12 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||
}
|
||||
case let .callEnded(contact):
|
||||
m.activeCallInvitation = nil
|
||||
withCall(contact) { _ in
|
||||
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||
}
|
||||
withCall(contact) { call in
|
||||
m.callCommand = .end
|
||||
CallController.shared.reportCallRemoteEnded(call: call)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
|
||||
@@ -14,17 +14,23 @@ let logger = Logger()
|
||||
struct SimpleXApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var userAuthorized: Bool? = nil
|
||||
@State private var doAuthenticate: Bool = false
|
||||
@State private var enteredBackground: Double? = nil
|
||||
|
||||
init() {
|
||||
hs_init(0, nil)
|
||||
UserDefaults.standard.register(defaults: appDefaults)
|
||||
BGManager.shared.register()
|
||||
NtfManager.shared.registerCategories()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
return WindowGroup {
|
||||
ContentView()
|
||||
ContentView(doAuthenticate: $doAuthenticate, enteredBackground: $enteredBackground)
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
@@ -36,8 +42,15 @@ struct SimpleXApp: App {
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase \(String(describing: scenePhase))")
|
||||
setAppState(phase)
|
||||
if phase == .background {
|
||||
switch (phase) {
|
||||
case .background:
|
||||
BGManager.shared.schedule()
|
||||
doAuthenticate = false
|
||||
enteredBackground = ProcessInfo.processInfo.systemUptime
|
||||
case .active:
|
||||
doAuthenticate = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,160 +7,211 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct ActiveCallView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var coordinator: WebRTCCoordinator? = nil
|
||||
@State private var webViewReady: Bool = false
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var call: Call
|
||||
@State private var rtcWebView: WKWebView? = nil
|
||||
@State private var webViewMsg: WVAPIMessage? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg)
|
||||
WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg)
|
||||
.onAppear() { sendCommandToWebView() }
|
||||
.onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() }
|
||||
.onChange(of: webViewReady) { _ in sendCommandToWebView() }
|
||||
.onChange(of: m.callCommand) { _ in sendCommandToWebView() }
|
||||
.onChange(of: rtcWebView) { _ in sendCommandToWebView() }
|
||||
.onChange(of: webViewMsg) { _ in processWebViewMessage() }
|
||||
.background(.black)
|
||||
ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() })
|
||||
if let call = m.activeCall, let webView = rtcWebView {
|
||||
ActiveCallOverlay(call: call, webView: webView)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private func sendCommandToWebView() {
|
||||
if chatModel.activeCall != nil && webViewReady,
|
||||
let cmd = chatModel.callCommand,
|
||||
let c = coordinator {
|
||||
chatModel.callCommand = nil
|
||||
logger.debug("ActiveCallView: command \(cmd.cmdType)")
|
||||
c.sendCommand(command: cmd)
|
||||
if m.activeCall != nil,
|
||||
let wv = rtcWebView,
|
||||
let cmd = m.callCommand {
|
||||
m.callCommand = nil
|
||||
sendCallCommand(wv, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
private func processWebViewMessage() {
|
||||
let m = chatModel
|
||||
if let msg = webViewMsg,
|
||||
let call = chatModel.activeCall {
|
||||
let call = m.activeCall,
|
||||
let webView = rtcWebView {
|
||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||
Task {
|
||||
switch msg.resp {
|
||||
case let .capabilities(capabilities):
|
||||
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
||||
try await apiSendCallInvitation(call.contact, callType)
|
||||
m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities)
|
||||
case let .offer(offer, iceCandidates, capabilities):
|
||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities)
|
||||
case let .answer(answer, iceCandidates):
|
||||
try await apiSendCallAnswer(call.contact, answer, iceCandidates)
|
||||
m.activeCall = call.copy(callState: .negotiated)
|
||||
case let .ice(iceCandidates):
|
||||
try await apiSendCallExtraInfo(call.contact, iceCandidates)
|
||||
case let .connection(state):
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||
case .connected = callStatus {
|
||||
m.activeCall = call.copy(callState: .connected)
|
||||
switch msg.resp {
|
||||
case let .capabilities(capabilities):
|
||||
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallInvitation(call.contact, callType)
|
||||
} catch {
|
||||
logger.error("apiSendCallInvitation \(responseError(error))")
|
||||
}
|
||||
try await apiCallStatus(call.contact, state.connectionState)
|
||||
case let .connected(connectionInfo):
|
||||
m.activeCall = call.copy(callState: .connected, connectionInfo: connectionInfo)
|
||||
case .ended:
|
||||
m.activeCall = nil
|
||||
m.activeCallInvitation = nil
|
||||
m.callCommand = nil
|
||||
m.showCallView = false
|
||||
case .ok:
|
||||
switch msg.command {
|
||||
case let .media(media, enable):
|
||||
switch media {
|
||||
case .video: m.activeCall = call.copy(videoEnabled: enable)
|
||||
case .audio: m.activeCall = call.copy(audioEnabled: enable)
|
||||
}
|
||||
case let .camera(camera):
|
||||
m.activeCall = call.copy(localCamera: camera)
|
||||
case .end:
|
||||
m.activeCall = nil
|
||||
m.activeCallInvitation = nil
|
||||
m.callCommand = nil
|
||||
m.showCallView = false
|
||||
default: ()
|
||||
DispatchQueue.main.async {
|
||||
call.callState = .invitationSent
|
||||
call.localCapabilities = capabilities
|
||||
}
|
||||
case let .error(message):
|
||||
logger.debug("ActiveCallView: command error: \(message)")
|
||||
case let .invalid(type):
|
||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||
}
|
||||
case let .offer(offer, iceCandidates, capabilities):
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
} catch {
|
||||
logger.error("apiSendCallOffer \(responseError(error))")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
call.callState = .offerSent
|
||||
call.localCapabilities = capabilities
|
||||
}
|
||||
}
|
||||
case let .answer(answer, iceCandidates):
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallAnswer(call.contact, answer, iceCandidates)
|
||||
} catch {
|
||||
logger.error("apiSendCallAnswer \(responseError(error))")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
call.callState = .negotiated
|
||||
}
|
||||
}
|
||||
case let .ice(iceCandidates):
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallExtraInfo(call.contact, iceCandidates)
|
||||
} catch {
|
||||
logger.error("apiSendCallExtraInfo \(responseError(error))")
|
||||
}
|
||||
}
|
||||
case let .connection(state):
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||
case .connected = callStatus {
|
||||
if case .outgoing = call.direction {
|
||||
CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||
}
|
||||
call.callState = .connected
|
||||
// CallKit doesn't work well with WKWebView
|
||||
// This is a hack to enable microphone in WKWebView after CallKit takes over it
|
||||
if CallController.useCallKit {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
m.callCommand = .camera(camera: call.localCamera)
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await apiCallStatus(call.contact, state.connectionState)
|
||||
} catch {
|
||||
logger.error("apiCallStatus \(responseError(error))")
|
||||
}
|
||||
}
|
||||
case let .connected(connectionInfo):
|
||||
call.callState = .connected
|
||||
call.connectionInfo = connectionInfo
|
||||
case .ended:
|
||||
closeCallView(webView)
|
||||
call.callState = .ended
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
}
|
||||
case .ok:
|
||||
switch msg.command {
|
||||
case .answer:
|
||||
call.callState = .negotiated
|
||||
case let .camera(camera):
|
||||
call.localCamera = camera
|
||||
Task {
|
||||
// This disables microphone if it was disabled before flipping the camera
|
||||
await webView.setMicrophoneCaptureState(call.audioEnabled ? .active : .muted)
|
||||
// This compensates for the bug on some devices when remote video does not appear
|
||||
// await webView.setCameraCaptureState(.muted)
|
||||
// await webView.setCameraCaptureState(call.videoEnabled ? .active : .muted)
|
||||
}
|
||||
case .end:
|
||||
closeCallView(webView)
|
||||
m.activeCall = nil
|
||||
default: ()
|
||||
}
|
||||
case let .error(message):
|
||||
logger.debug("ActiveCallView: command error: \(message)")
|
||||
case let .invalid(type):
|
||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func closeCallView(_ webView: WKWebView) {
|
||||
m.showCallView = false
|
||||
Task {
|
||||
await webView.setMicrophoneCaptureState(.muted)
|
||||
await webView.setCameraCaptureState(.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveCallOverlay: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
var call: Call?
|
||||
var dismiss: () -> Void
|
||||
@ObservedObject var call: Call
|
||||
var webView: WKWebView
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if let call = call {
|
||||
switch call.localMedia {
|
||||
case .video:
|
||||
callInfoView(call, .leading)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
switch call.localMedia {
|
||||
case .video:
|
||||
callInfoView(call, .leading)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
toggleAudioButton()
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
||||
chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
|
||||
}
|
||||
Spacer()
|
||||
Color.clear.frame(width: 40, height: 40)
|
||||
Spacer()
|
||||
endCallButton()
|
||||
Spacer()
|
||||
if call.videoEnabled {
|
||||
flipCameraButton()
|
||||
} else {
|
||||
Color.clear.frame(width: 40, height: 40)
|
||||
Spacer()
|
||||
callButton("phone.down.fill", size: 60) { dismiss() }
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
controlButton(call, "arrow.triangle.2.circlepath") {
|
||||
chatModel.callCommand = .camera(camera: call.localCamera == .user ? .environment : .user)
|
||||
}
|
||||
Spacer()
|
||||
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
|
||||
chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
case .audio:
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
callInfoView(call, .center)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
||||
chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
callButton("phone.down.fill", size: 60) { dismiss() }
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.padding(.bottom, 60)
|
||||
.padding(.horizontal, 48)
|
||||
toggleVideoButton()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
case .audio:
|
||||
VStack {
|
||||
ProfileImage(imageStr: call.contact.profile.image)
|
||||
.scaledToFit()
|
||||
.frame(width: 192, height: 192)
|
||||
callInfoView(call, .center)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
toggleAudioButton()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
endCallButton()
|
||||
}
|
||||
.padding(.bottom, 60)
|
||||
.padding(.horizontal, 48)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -186,6 +237,57 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", size: 60) {
|
||||
if let uuid = call.callkitUUID {
|
||||
cc.endCall(callUUID: uuid)
|
||||
} else {
|
||||
cc.endCall(call: call) {}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
private func toggleAudioButton() -> some View {
|
||||
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
||||
Task {
|
||||
await webView.setMicrophoneCaptureState(call.audioEnabled ? .muted : .active)
|
||||
DispatchQueue.main.async {
|
||||
call.audioEnabled = !call.audioEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleVideoButton() -> some View {
|
||||
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
|
||||
Task {
|
||||
await webView.setCameraCaptureState(call.videoEnabled ? .muted : .active)
|
||||
DispatchQueue.main.async {
|
||||
call.videoEnabled = !call.videoEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func flipCameraButton() -> some View {
|
||||
let cmd = WCallCommand.camera(camera: call.localCamera == .user ? .environment : .user)
|
||||
controlButton(call, "arrow.triangle.2.circlepath") {
|
||||
if call.audioEnabled {
|
||||
chatModel.callCommand = cmd
|
||||
} else {
|
||||
Task {
|
||||
// Microphone has to be enabled before flipping the camera to avoid prompt for user permission when getUserMedia is called in webview
|
||||
await webView.setMicrophoneCaptureState(.active)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.callCommand = cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
|
||||
if call.hasMedia {
|
||||
callButton(imageName, size: 40, perform)
|
||||
@@ -211,9 +313,9 @@ struct ActiveCallOverlay: View {
|
||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {})
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView())
|
||||
.background(.black)
|
||||
ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {})
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView())
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// CallController.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import AVFoundation
|
||||
|
||||
class CallController: NSObject, CXProviderDelegate, ObservableObject {
|
||||
static let useCallKit = false
|
||||
static let shared = CallController()
|
||||
private let provider = CXProvider(configuration: CallController.configuration)
|
||||
private let controller = CXCallController()
|
||||
private let callManager = CallManager()
|
||||
@Published var activeCallInvitation: CallInvitation?
|
||||
|
||||
// PKPushRegistry will be used from notification service extension
|
||||
// let registry = PKPushRegistry(queue: nil)
|
||||
|
||||
static let configuration: CXProviderConfiguration = {
|
||||
let configuration = CXProviderConfiguration()
|
||||
configuration.supportsVideo = true
|
||||
configuration.supportedHandleTypes = [.generic]
|
||||
configuration.includesCallsInRecents = true // TODO disable or add option
|
||||
configuration.maximumCallsPerCallGroup = 1
|
||||
return configuration
|
||||
}()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.provider.setDelegate(self, queue: nil)
|
||||
// self.registry.delegate = self
|
||||
// self.registry.desiredPushTypes = [.voIP]
|
||||
}
|
||||
|
||||
func providerDidReset(_ provider: CXProvider) {
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
logger.debug("CallController.provider CXEndCallAction")
|
||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
||||
if ok {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
||||
print("timed out", #function)
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("received", #function)
|
||||
// do {
|
||||
// try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
|
||||
// logger.debug("audioSession category set")
|
||||
// try audioSession.setActive(true)
|
||||
// logger.debug("audioSession activated")
|
||||
// } catch {
|
||||
// print(error)
|
||||
// logger.error("failed activating audio session")
|
||||
// }
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("received", #function)
|
||||
}
|
||||
|
||||
// func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||
//
|
||||
// }
|
||||
|
||||
// This will be needed when we have notification service extension
|
||||
// func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||
// if type == .voIP {
|
||||
// // Extract the call information from the push notification payload
|
||||
// if let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||
// let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
// let uuidStr = payload.dictionaryPayload["uuid"] as? String,
|
||||
// let uuid = UUID(uuidString: uuidStr) {
|
||||
// let callUpdate = CXCallUpdate()
|
||||
// callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: displayName)
|
||||
// provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in
|
||||
// if error != nil {
|
||||
// let m = ChatModel.shared
|
||||
// m.callInvitations.removeValue(forKey: contactId)
|
||||
// }
|
||||
// // Tell PushKit that the notification is handled.
|
||||
// completion()
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall")
|
||||
if !UserDefaults.standard.bool(forKey: DEFAULT_EXPERIMENTAL_CALLS) { return }
|
||||
if CallController.useCallKit, let uuid = invitation.callkitUUID {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName)
|
||||
update.hasVideo = invitation.peerMedia == .video
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
} else {
|
||||
NtfManager.shared.notifyCallInvitation(invitation)
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
activeCallInvitation = invitation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
if CallController.useCallKit, let uuid = call.callkitUUID {
|
||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func reportCallRemoteEnded(invitation: CallInvitation) {
|
||||
if CallController.useCallKit, let uuid = invitation.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||
activeCallInvitation = nil
|
||||
}
|
||||
}
|
||||
|
||||
func reportCallRemoteEnded(call: Call) {
|
||||
if CallController.useCallKit, let uuid = call.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||
logger.debug("CallController.startCall")
|
||||
let uuid = callManager.newOutgoingCall(contact, media)
|
||||
if CallController.useCallKit {
|
||||
let handle = CXHandle(type: .generic, value: contact.displayName)
|
||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||
action.isVideo = media == .video
|
||||
requestTransaction(with: action)
|
||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
logger.debug("CallController.startCall: call started")
|
||||
} else {
|
||||
logger.error("CallController.startCall: no active call")
|
||||
}
|
||||
}
|
||||
|
||||
func answerCall(invitation: CallInvitation) {
|
||||
callManager.answerIncomingCall(invitation: invitation)
|
||||
if invitation.contact.id == self.activeCallInvitation?.contact.id {
|
||||
self.activeCallInvitation = nil
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(callUUID: UUID) {
|
||||
if CallController.useCallKit {
|
||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
||||
} else {
|
||||
callManager.endCall(callUUID: callUUID) { ok in
|
||||
if ok {
|
||||
logger.debug("CallController.endCall: call ended")
|
||||
} else {
|
||||
logger.error("CallController.endCall: no actove call pr call invitation to end")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(invitation: CallInvitation) {
|
||||
callManager.endCall(invitation: invitation) {
|
||||
if invitation.contact.id == self.activeCallInvitation?.contact.id {
|
||||
DispatchQueue.main.async {
|
||||
self.activeCallInvitation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(call: Call, completed: @escaping () -> Void) {
|
||||
callManager.endCall(call: call, completed: completed)
|
||||
}
|
||||
|
||||
private func requestTransaction(with action: CXAction) {
|
||||
let t = CXTransaction()
|
||||
t.addAction(action)
|
||||
controller.request(t) { error in
|
||||
if let error = error {
|
||||
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)")
|
||||
} else {
|
||||
logger.debug("CallController.requestTransaction requested transaction successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// CallManager.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 22/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class CallManager {
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
||||
let uuid = UUID()
|
||||
ChatModel.shared.activeCall = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
return uuid
|
||||
}
|
||||
|
||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
m.callCommand = .capabilities(media: call.localMedia, useWorker: true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
||||
if let invitation = getCallInvitation(callUUID) {
|
||||
answerIncomingCall(invitation: invitation)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func answerIncomingCall(invitation: CallInvitation) {
|
||||
let m = ChatModel.shared
|
||||
m.callInvitations.removeValue(forKey: invitation.contact.id)
|
||||
m.activeCall = Call(
|
||||
direction: .incoming,
|
||||
contact: invitation.contact,
|
||||
callkitUUID: invitation.callkitUUID,
|
||||
callState: .invitationAccepted,
|
||||
localMedia: invitation.peerMedia,
|
||||
sharedKey: invitation.sharedKey
|
||||
)
|
||||
m.showCallView = true
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
logger.debug("answerIncomingCall useRelay \(useRelay)")
|
||||
m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
|
||||
}
|
||||
|
||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
endCall(call: call) { completed(true) }
|
||||
} else if let invitation = getCallInvitation(callUUID) {
|
||||
endCall(invitation: invitation) { completed(true) }
|
||||
} else {
|
||||
completed(false)
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(call: Call, completed: @escaping () -> Void) {
|
||||
let m = ChatModel.shared
|
||||
if case .ended = call.callState {
|
||||
logger.debug("CallManager.endCall: call ended")
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
} else {
|
||||
logger.debug("CallManager.endCall: ending call...")
|
||||
m.callCommand = .end
|
||||
m.showCallView = false
|
||||
Task {
|
||||
do {
|
||||
try await apiEndCall(call.contact)
|
||||
} catch {
|
||||
logger.error("CallController.provider apiEndCall error: \(responseError(error))")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
m.activeCall = nil
|
||||
completed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(invitation: CallInvitation, completed: @escaping () -> Void) {
|
||||
ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id)
|
||||
Task {
|
||||
do {
|
||||
try await apiRejectCall(invitation.contact)
|
||||
} catch {
|
||||
logger.error("CallController.provider apiRejectCall error: \(responseError(error))")
|
||||
}
|
||||
completed()
|
||||
}
|
||||
}
|
||||
|
||||
private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
||||
return invitation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// IncomingCallView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 24/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IncomingCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var cc = CallController.shared
|
||||
|
||||
var body: some View {
|
||||
let sp = SoundPlayer.shared
|
||||
if let invitation = cc.activeCallInvitation {
|
||||
if m.showCallView {
|
||||
incomingCall(invitation)
|
||||
} else {
|
||||
incomingCall(invitation)
|
||||
.onAppear { sp.startRingtone() }
|
||||
.onDisappear { sp.stopRingtone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func incomingCall(_ invitation: CallInvitation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
|
||||
Text(invitation.callTypeText)
|
||||
}
|
||||
HStack {
|
||||
ProfilePreview(profileOf: invitation.contact, color: .white)
|
||||
Spacer()
|
||||
|
||||
callButton("Reject", "phone.down.fill", .red) {
|
||||
cc.endCall(invitation: invitation)
|
||||
}
|
||||
|
||||
callButton("Ignore", "multiply", .accentColor) {
|
||||
cc.activeCallInvitation = nil
|
||||
}
|
||||
|
||||
callButton("Accept", "checkmark", .green) {
|
||||
if let call = m.activeCall {
|
||||
cc.endCall(call: call) {
|
||||
DispatchQueue.main.async {
|
||||
cc.answerCall(invitation: invitation)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cc.answerCall(invitation: invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
}
|
||||
|
||||
private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action, label: {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: image)
|
||||
.scaleEffect(1.24)
|
||||
.foregroundColor(color)
|
||||
.frame(width: 24, height: 24)
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(minWidth: 44)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct IncomingCallView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CallController.shared.activeCallInvitation = CallInvitation.sampleData
|
||||
return IncomingCallView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// SoundPlayer.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 24/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
class SoundPlayer {
|
||||
static let shared = SoundPlayer()
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
|
||||
func startRingtone() {
|
||||
audioPlayer?.stop()
|
||||
logger.debug("startRingtone")
|
||||
guard let path = Bundle.main.path(forResource: "ringtone2", ofType: "m4a", inDirectory: "sounds") else {
|
||||
logger.debug("startRingtone: file not found")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
|
||||
if player.prepareToPlay() {
|
||||
audioPlayer = player
|
||||
}
|
||||
} catch {
|
||||
logger.debug("startRingtone: AVAudioPlayer error \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
Task {
|
||||
while let player = audioPlayer {
|
||||
player.play()
|
||||
AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
|
||||
_ = try? await Task.sleep(nanoseconds: UInt64(player.duration * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRingtone() {
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
}
|
||||
}
|
||||
@@ -9,70 +9,39 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class Call: Equatable {
|
||||
class Call: ObservableObject, Equatable {
|
||||
static func == (lhs: Call, rhs: Call) -> Bool {
|
||||
lhs.contact.apiId == rhs.contact.apiId
|
||||
}
|
||||
|
||||
var direction: CallDirection
|
||||
var contact: Contact
|
||||
var callState: CallState
|
||||
var callkitUUID: UUID?
|
||||
var localMedia: CallMediaType
|
||||
var localCapabilities: CallCapabilities?
|
||||
var peerMedia: CallMediaType?
|
||||
var sharedKey: String?
|
||||
var audioEnabled: Bool
|
||||
var videoEnabled: Bool
|
||||
var localCamera: VideoCamera
|
||||
var connectionInfo: ConnectionInfo?
|
||||
@Published var callState: CallState
|
||||
@Published var localCapabilities: CallCapabilities?
|
||||
@Published var peerMedia: CallMediaType?
|
||||
@Published var sharedKey: String?
|
||||
@Published var audioEnabled = true
|
||||
@Published var videoEnabled: Bool
|
||||
@Published var localCamera = VideoCamera.user
|
||||
@Published var connectionInfo: ConnectionInfo?
|
||||
|
||||
init(
|
||||
direction: CallDirection,
|
||||
contact: Contact,
|
||||
callkitUUID: UUID?,
|
||||
callState: CallState,
|
||||
localMedia: CallMediaType,
|
||||
localCapabilities: CallCapabilities? = nil,
|
||||
peerMedia: CallMediaType? = nil,
|
||||
sharedKey: String? = nil,
|
||||
audioEnabled: Bool? = nil,
|
||||
videoEnabled: Bool? = nil,
|
||||
localCamera: VideoCamera = .user,
|
||||
connectionInfo: ConnectionInfo? = nil
|
||||
sharedKey: String? = nil
|
||||
) {
|
||||
self.direction = direction
|
||||
self.contact = contact
|
||||
self.callkitUUID = callkitUUID
|
||||
self.callState = callState
|
||||
self.localMedia = localMedia
|
||||
self.localCapabilities = localCapabilities
|
||||
self.peerMedia = peerMedia
|
||||
self.sharedKey = sharedKey
|
||||
self.audioEnabled = audioEnabled ?? true
|
||||
self.videoEnabled = videoEnabled ?? (localMedia == .video)
|
||||
self.localCamera = localCamera
|
||||
self.connectionInfo = connectionInfo
|
||||
}
|
||||
|
||||
func copy(
|
||||
contact: Contact? = nil,
|
||||
callState: CallState? = nil,
|
||||
localMedia: CallMediaType? = nil,
|
||||
localCapabilities: CallCapabilities? = nil,
|
||||
peerMedia: CallMediaType? = nil,
|
||||
sharedKey: String? = nil,
|
||||
audioEnabled: Bool? = nil,
|
||||
videoEnabled: Bool? = nil,
|
||||
localCamera: VideoCamera? = nil,
|
||||
connectionInfo: ConnectionInfo? = nil
|
||||
) -> Call {
|
||||
Call (
|
||||
contact: contact ?? self.contact,
|
||||
callState: callState ?? self.callState,
|
||||
localMedia: localMedia ?? self.localMedia,
|
||||
localCapabilities: localCapabilities ?? self.localCapabilities,
|
||||
peerMedia: peerMedia ?? self.peerMedia,
|
||||
sharedKey: sharedKey ?? self.sharedKey,
|
||||
audioEnabled: audioEnabled ?? self.audioEnabled,
|
||||
videoEnabled: videoEnabled ?? self.videoEnabled,
|
||||
localCamera: localCamera ?? self.localCamera,
|
||||
connectionInfo: connectionInfo ?? self.connectionInfo
|
||||
)
|
||||
self.videoEnabled = localMedia == .video
|
||||
}
|
||||
|
||||
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
|
||||
@@ -82,7 +51,7 @@ class Call: Equatable {
|
||||
switch callState {
|
||||
case .waitCapabilities: return ""
|
||||
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
|
||||
case .invitationReceived: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
||||
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
||||
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
||||
}
|
||||
}
|
||||
@@ -90,24 +59,33 @@ class Call: Equatable {
|
||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||
}
|
||||
|
||||
enum CallDirection {
|
||||
case incoming
|
||||
case outgoing
|
||||
}
|
||||
|
||||
enum CallState {
|
||||
case waitCapabilities
|
||||
case invitationSent
|
||||
case invitationReceived
|
||||
case offerSent
|
||||
case offerReceived
|
||||
case negotiated
|
||||
case waitCapabilities // outgoing call started
|
||||
case invitationSent // outgoing call - sent invitation
|
||||
case invitationAccepted // incoming call started
|
||||
case offerSent // incoming - webrtc started and offer sent
|
||||
case offerReceived // outgoing - webrtc offer received via API
|
||||
case answerReceived // incoming - webrtc answer received via API
|
||||
case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed
|
||||
case connected
|
||||
case ended
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .waitCapabilities: return "starting…"
|
||||
case .invitationSent: return "waiting for answer…"
|
||||
case .invitationReceived: return "starting…"
|
||||
case .invitationAccepted: return "starting…"
|
||||
case .offerSent: return "waiting for confirmation…"
|
||||
case .offerReceived: return "received answer…"
|
||||
case .answerReceived: return "received confirmation…"
|
||||
case .negotiated: return "connecting…"
|
||||
case .connected: return "connected"
|
||||
case .ended: return "ended"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +102,7 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
|
||||
}
|
||||
|
||||
enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
case capabilities(useWorker: Bool? = nil)
|
||||
case capabilities(media: CallMediaType, useWorker: Bool? = nil)
|
||||
case start(media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case answer(answer: String, iceCandidates: String)
|
||||
@@ -165,8 +143,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case let .capabilities(useWorker):
|
||||
case let .capabilities(media, useWorker):
|
||||
try container.encode("capabilities", forKey: .type)
|
||||
try container.encode(media, forKey: .media)
|
||||
try container.encode(useWorker, forKey: .useWorker)
|
||||
case let .start(media, aesKey, useWorker, iceServers, relay):
|
||||
try container.encode("start", forKey: .type)
|
||||
@@ -208,8 +187,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
let type = try container.decode(String.self, forKey: CodingKeys.type)
|
||||
switch type {
|
||||
case "capabilities":
|
||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
|
||||
self = .capabilities(useWorker: useWorker)
|
||||
self = .capabilities(media: media, useWorker: useWorker)
|
||||
case "start":
|
||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
|
||||
@@ -275,16 +255,16 @@ enum WCallResponse: Equatable, Decodable {
|
||||
var respType: String {
|
||||
get {
|
||||
switch self {
|
||||
case .capabilities: return("capabilities")
|
||||
case .offer: return("offer")
|
||||
case .answer: return("answer")
|
||||
case .ice: return("ice")
|
||||
case .connection: return("connection")
|
||||
case .connected: return("connected")
|
||||
case .ended: return("ended")
|
||||
case .ok: return("ok")
|
||||
case .error: return("error")
|
||||
case .invalid: return("invalid")
|
||||
case .capabilities: return "capabilities"
|
||||
case .offer: return "offer"
|
||||
case .answer: return "answer"
|
||||
case .ice: return "ice"
|
||||
case .connection: return "connection"
|
||||
case .connected: return "connected"
|
||||
case .ended: return "ended"
|
||||
case .ok: return "ok"
|
||||
case .error: return "error"
|
||||
case .invalid: return "invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,24 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
var webViewReady: Binding<Bool>
|
||||
class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate {
|
||||
var rtcWebView: Binding<WKWebView?>
|
||||
var webViewMsg: Binding<WVAPIMessage?>
|
||||
private var webView: WKWebView?
|
||||
|
||||
internal init(webViewReady: Binding<Bool>, webViewMsg: Binding<WVAPIMessage?>) {
|
||||
self.webViewReady = webViewReady
|
||||
internal init(rtcWebView: Binding<WKWebView?>, webViewMsg: Binding<WVAPIMessage?>) {
|
||||
self.rtcWebView = rtcWebView
|
||||
self.webViewMsg = webViewMsg
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
self.webView = webView
|
||||
webViewReady.wrappedValue = true
|
||||
self.rtcWebView.wrappedValue = webView
|
||||
ChatModel.shared.callWebView = webView
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin : WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||
print("webView", #function)
|
||||
return .grant
|
||||
}
|
||||
|
||||
// receive message from WKWebView
|
||||
@@ -31,34 +35,37 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
logger.debug("WebRTCCoordinator.userContentController")
|
||||
if let msgStr = message.body as? String,
|
||||
let msg: WVAPIMessage = decodeJSON(msgStr) {
|
||||
webViewMsg.wrappedValue = msg
|
||||
if case .invalid = msg.resp {
|
||||
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
|
||||
switch message.name {
|
||||
case "webrtc":
|
||||
if let msgStr = message.body as? String,
|
||||
let msg: WVAPIMessage = decodeJSON(msgStr) {
|
||||
// this is the binding that communicates messages from webview to swift view
|
||||
webViewMsg.wrappedValue = msg
|
||||
if case .invalid = msg.resp {
|
||||
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
|
||||
}
|
||||
} else {
|
||||
logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))")
|
||||
}
|
||||
} else {
|
||||
logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))")
|
||||
}
|
||||
}
|
||||
|
||||
func sendCommand(command: WCallCommand) {
|
||||
if let webView = webView {
|
||||
logger.debug("WebRTCCoordinator.sendCommand")
|
||||
let apiCmd = encodeJSON(WVAPICall(command: command))
|
||||
let js = "processCommand(\(apiCmd))"
|
||||
webView.evaluateJavaScript(js)
|
||||
case "logger":
|
||||
if let msgStr = message.body as? String {
|
||||
logger.error("WebRTCCoordinator console.log: \(msgStr)")
|
||||
} else {
|
||||
logger.error("WebRTCCoordinator console.log: \(String(describing: message.body))")
|
||||
}
|
||||
default:
|
||||
logger.error("WebRTCCoordinator.userContentController: invalid message.name \(message.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WebRTCView: UIViewRepresentable {
|
||||
@Binding var coordinator: WebRTCCoordinator?
|
||||
@Binding var webViewReady: Bool
|
||||
@State private var coordinator: WebRTCCoordinator?
|
||||
@Binding var rtcWebView: WKWebView?
|
||||
@Binding var webViewMsg: WVAPIMessage?
|
||||
|
||||
func makeCoordinator() -> WebRTCCoordinator {
|
||||
WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg)
|
||||
WebRTCCoordinator(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
@@ -72,10 +79,14 @@ struct WebRTCView: UIViewRepresentable {
|
||||
cfg.mediaTypesRequiringUserActionForPlayback = []
|
||||
cfg.allowsInlineMediaPlayback = true
|
||||
|
||||
let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))"
|
||||
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
||||
wkController.addUserScript(script)
|
||||
wkController.add(wkCoordinator, name: "webrtc")
|
||||
let addScript = { (handler: String, source: String) in
|
||||
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
||||
wkController.addUserScript(script)
|
||||
wkController.add(wkCoordinator, name: handler)
|
||||
}
|
||||
|
||||
addScript("webrtc", "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))")
|
||||
addScript("logger", "console.log = (arg) => webkit.messageHandlers.logger.postMessage(JSON.stringify(arg))")
|
||||
|
||||
let wkWebView = WKWebView(frame: .zero, configuration: cfg)
|
||||
wkWebView.navigationDelegate = wkCoordinator
|
||||
@@ -93,79 +104,79 @@ struct WebRTCView: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CallViewDebug: View {
|
||||
@State private var coordinator: WebRTCCoordinator? = nil
|
||||
@State private var commandStr = ""
|
||||
@State private var webViewReady: Bool = false
|
||||
@State private var webViewMsg: WVAPIMessage? = nil
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 30) {
|
||||
WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260)
|
||||
.onChange(of: webViewMsg) { _ in
|
||||
if let resp = webViewMsg {
|
||||
commandStr = encodeJSON(resp)
|
||||
}
|
||||
}
|
||||
TextEditor(text: $commandStr)
|
||||
.focused($keyboardVisible)
|
||||
.disableAutocorrection(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.top, 2)
|
||||
.frame(height: 112)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
HStack(spacing: 20) {
|
||||
Button("Copy") {
|
||||
UIPasteboard.general.string = commandStr
|
||||
}
|
||||
Button("Paste") {
|
||||
commandStr = UIPasteboard.general.string ?? ""
|
||||
}
|
||||
Button("Clear") {
|
||||
commandStr = ""
|
||||
}
|
||||
Button("Send") {
|
||||
if let c = coordinator,
|
||||
let command: WCallCommand = decodeJSON(commandStr) {
|
||||
c.sendCommand(command: command)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: 20) {
|
||||
Button("Capabilities") {
|
||||
if let c = coordinator {
|
||||
c.sendCommand(command: .capabilities(useWorker: true))
|
||||
}
|
||||
}
|
||||
Button("Start") {
|
||||
if let c = coordinator {
|
||||
c.sendCommand(command: .start(media: .video))
|
||||
}
|
||||
}
|
||||
Button("Accept") {
|
||||
|
||||
}
|
||||
Button("Answer") {
|
||||
|
||||
}
|
||||
Button("ICE") {
|
||||
|
||||
}
|
||||
Button("End") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) {
|
||||
logger.debug("sendCallCommand: \(command.cmdType)")
|
||||
let apiCmd = encodeJSON(WVAPICall(command: command))
|
||||
let js = "processCommand(\(apiCmd))"
|
||||
webView.evaluateJavaScript(js)
|
||||
}
|
||||
|
||||
//struct CallViewDebug: View {
|
||||
// @State private var commandStr = ""
|
||||
// @State private var rtcWebView: WKWebView? = nil
|
||||
// @State private var webViewMsg: WVAPIMessage? = nil
|
||||
// @FocusState private var keyboardVisible: Bool
|
||||
//
|
||||
//struct CallViewDebug_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// CallViewDebug()
|
||||
// var body: some View {
|
||||
// VStack(spacing: 30) {
|
||||
// WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg).frame(maxHeight: 260)
|
||||
// .onChange(of: webViewMsg) { _ in
|
||||
// if let resp = webViewMsg {
|
||||
// commandStr = encodeJSON(resp)
|
||||
// }
|
||||
// }
|
||||
// TextEditor(text: $commandStr)
|
||||
// .focused($keyboardVisible)
|
||||
// .disableAutocorrection(true)
|
||||
// .textInputAutocapitalization(.never)
|
||||
// .padding(.horizontal, 5)
|
||||
// .padding(.top, 2)
|
||||
// .frame(height: 112)
|
||||
// .overlay(
|
||||
// RoundedRectangle(cornerRadius: 10)
|
||||
// .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
// )
|
||||
// HStack(spacing: 20) {
|
||||
// Button("Copy") {
|
||||
// UIPasteboard.general.string = commandStr
|
||||
// }
|
||||
// Button("Paste") {
|
||||
// commandStr = UIPasteboard.general.string ?? ""
|
||||
// }
|
||||
// Button("Clear") {
|
||||
// commandStr = ""
|
||||
// }
|
||||
// Button("Send") {
|
||||
// if let wv = rtcWebView,
|
||||
// let command: WCallCommand = decodeJSON(commandStr) {
|
||||
// sendCallCommand(wv, command)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// HStack(spacing: 20) {
|
||||
// Button("Capabilities") {
|
||||
// if let wv = rtcWebView {
|
||||
// sendCallCommand(wv, .capabilities(useWorker: true))
|
||||
// }
|
||||
// }
|
||||
// Button("Start") {
|
||||
// if let wv = rtcWebView {
|
||||
// sendCallCommand(wv, .start(media: .video))
|
||||
// }
|
||||
// }
|
||||
// Button("Accept") {
|
||||
//
|
||||
// }
|
||||
// Button("Answer") {
|
||||
//
|
||||
// }
|
||||
// Button("ICE") {
|
||||
//
|
||||
// }
|
||||
// Button("End") {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -12,7 +12,6 @@ struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var showChatInfo: Bool
|
||||
@State var alert: ChatInfoViewAlert? = nil
|
||||
@State var deletingContact: Contact?
|
||||
|
||||
@@ -99,7 +98,7 @@ struct ChatInfoView: View {
|
||||
try await apiDeleteChat(type: .direct, id: contact.apiId)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.removeChat(contact.id)
|
||||
showChatInfo = false
|
||||
chatModel.showChatInfo = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
@@ -118,7 +117,7 @@ struct ChatInfoView: View {
|
||||
Task {
|
||||
await clearChat(chat)
|
||||
DispatchQueue.main.async {
|
||||
showChatInfo = false
|
||||
chatModel.showChatInfo = false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -130,6 +129,6 @@ struct ChatInfoView: View {
|
||||
struct ChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var showChatInfo = true
|
||||
return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showChatInfo: $showChatInfo)
|
||||
return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ struct CICallItemView: View {
|
||||
acceptCallButton()
|
||||
}
|
||||
case .missed: missedCallIcon(sent).foregroundColor(.red)
|
||||
case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
|
||||
case .rejected: Image(systemName: "phone.down").foregroundColor(.red)
|
||||
case .accepted: connectingCallIcon()
|
||||
case .negotiated: connectingCallIcon()
|
||||
case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
|
||||
@@ -61,16 +61,9 @@ struct CICallItemView: View {
|
||||
@ViewBuilder private func acceptCallButton() -> some View {
|
||||
if case let .direct(contact) = chatInfo {
|
||||
Button {
|
||||
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||
m.activeCallInvitation = nil
|
||||
m.activeCall = Call(
|
||||
contact: contact,
|
||||
callState: .invitationReceived,
|
||||
localMedia: invitation.peerMedia,
|
||||
sharedKey: invitation.sharedKey
|
||||
)
|
||||
m.showCallView = true
|
||||
m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
if let invitation = m.callInvitations[contact.id] {
|
||||
CallController.shared.answerCall(invitation: invitation)
|
||||
logger.debug("acceptCallButton call answered")
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
}
|
||||
|
||||
+17
-5
@@ -47,11 +47,23 @@ struct CIImageView: View {
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
.onTapGesture {
|
||||
if case .rcvAccepted = file?.fileStatus {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for image",
|
||||
message: "Image will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
// TODO image accepted alert?
|
||||
}
|
||||
case .rcvAccepted:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for image",
|
||||
message: "Image will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
@@ -29,11 +28,6 @@ struct DeletedItemView: View {
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
// .background(Color(uiColor: .systemBackground))
|
||||
// .overlay(
|
||||
// RoundedRectangle(cornerRadius: 18)
|
||||
// .stroke(.quaternary, lineWidth: 1)
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// IntegrityErrorItemView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 28/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrityErrorItemView: View {
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture { skippedMessagesAlert() }
|
||||
}
|
||||
|
||||
private func skippedMessagesAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Skipped messages",
|
||||
message: """
|
||||
It can happen when:
|
||||
1. The messages expire on the server if they were not received for 30 days,
|
||||
2. The server you use to receive the messages from this contact was updated and restarted.
|
||||
3. The connection is compromised.
|
||||
Please connect to the developers via Settings to receive the updates about the servers.
|
||||
We will be adding server redundancy to prevent lost messages.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct IntegrityErrorItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IntegrityErrorItemView(chatItem: ChatItem.getIntegrityErrorSample())
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct ChatItemView: View {
|
||||
case .rcvDeleted: deletedItemView()
|
||||
case let .sndCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ private let memberImageSize: CGFloat = 34
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showChatInfo = false
|
||||
@State private var showDeleteMessage = false
|
||||
|
||||
var body: some View {
|
||||
@@ -97,35 +97,29 @@ struct ChatView: View {
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button {
|
||||
showChatInfo = true
|
||||
chatModel.showChatInfo = true
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.sheet(isPresented: $showChatInfo) {
|
||||
ChatInfoView(chat: chat, showChatInfo: $showChatInfo)
|
||||
.sheet(isPresented: $chatModel.showChatInfo) {
|
||||
ChatInfoView(chat: chat)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if enableCalls, case let .direct(contact) = cInfo {
|
||||
HStack {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
callButton(contact, .video, imageName: "video")
|
||||
}
|
||||
}
|
||||
}
|
||||
// ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// if case let .direct(contact) = cInfo {
|
||||
// HStack {
|
||||
// callButton(contact, .audio, imageName: "phone")
|
||||
// callButton(contact, .video, imageName: "video")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
|
||||
Button {
|
||||
chatModel.activeCall = Call(
|
||||
contact: contact,
|
||||
callState: .waitCapabilities,
|
||||
localMedia: media
|
||||
)
|
||||
chatModel.showCallView = true
|
||||
chatModel.callCommand = .capabilities(useWorker: true)
|
||||
CallController.shared.startCall(contact, media)
|
||||
} label: {
|
||||
Image(systemName: imageName)
|
||||
}
|
||||
|
||||
+30
-14
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview)
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
case imagePreview(imagePreview: String)
|
||||
case filePreview(fileName: String)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ struct ComposeState {
|
||||
var preview: ComposePreview
|
||||
var contextItem: ComposeContextItem
|
||||
var inProgress: Bool = false
|
||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
|
||||
init(
|
||||
message: String = "",
|
||||
@@ -80,7 +81,7 @@ struct ComposeState {
|
||||
case .filePreview:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
return useLinkPreviews
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +176,11 @@ struct ComposeView: View {
|
||||
Button("Choose from library") {
|
||||
showImagePicker = true
|
||||
}
|
||||
if UIPasteboard.general.hasImages {
|
||||
Button("Paste image") {
|
||||
chosenImage = UIPasteboard.general.image
|
||||
}
|
||||
}
|
||||
Button("Choose file") {
|
||||
showFileImporter = true
|
||||
}
|
||||
@@ -406,11 +412,13 @@ struct ComposeView: View {
|
||||
if let uri = composeState.linkPreview()?.uri.absoluteString {
|
||||
cancelledLinks.insert(uri)
|
||||
}
|
||||
pendingLinkUrl = nil
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
|
||||
private func loadLinkPreview(_ url: URL) {
|
||||
if pendingLinkUrl == url {
|
||||
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
|
||||
getLinkPreview(url: url) { linkPreview in
|
||||
if let linkPreview = linkPreview,
|
||||
pendingLinkUrl == url {
|
||||
@@ -432,6 +440,7 @@ struct ComposeView: View {
|
||||
switch (composeState.preview) {
|
||||
case let .linkPreview(linkPreview: linkPreview):
|
||||
if let url = parseMessage(composeState.message),
|
||||
let linkPreview = linkPreview,
|
||||
url == linkPreview.uri {
|
||||
return .link(text: composeState.message, preview: linkPreview)
|
||||
} else {
|
||||
|
||||
@@ -49,29 +49,6 @@ struct ChatListView: View {
|
||||
NewChatButton()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $chatModel.showCallView) {
|
||||
ActiveCallView()
|
||||
}
|
||||
.onChange(of: chatModel.showCallView) { _ in
|
||||
if (chatModel.showCallView) { return }
|
||||
if let call = chatModel.activeCall {
|
||||
Task {
|
||||
do {
|
||||
try await apiEndCall(call.contact)
|
||||
} catch {
|
||||
logger.error("ChatListView apiEndCall error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
chatModel.callCommand = .end
|
||||
}
|
||||
.onChange(of: chatModel.activeCallInvitation) { _ in
|
||||
if let contactRef = chatModel.activeCallInvitation,
|
||||
case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo,
|
||||
let invitation = chatModel.callInvitations[contactRef.id] {
|
||||
answerCallAlert(contact, invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
|
||||
@@ -95,34 +72,6 @@ struct ChatListView: View {
|
||||
$0.chatInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
|
||||
private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) {
|
||||
return AlertManager.shared.showAlert(Alert(
|
||||
title: Text(invitation.callTitle),
|
||||
message: Text(contact.profile.displayName).bold() +
|
||||
Text(" wants to connect with you via ") +
|
||||
Text(invitation.callTypeText),
|
||||
primaryButton: .default(Text("Answer")) {
|
||||
if let activeCallInvitation = chatModel.activeCallInvitation {
|
||||
chatModel.callInvitations.removeValue(forKey: activeCallInvitation.id)
|
||||
chatModel.activeCallInvitation = nil
|
||||
chatModel.activeCall = Call(
|
||||
contact: contact,
|
||||
callState: .invitationReceived,
|
||||
localMedia: invitation.peerMedia,
|
||||
sharedKey: invitation.sharedKey
|
||||
)
|
||||
chatModel.showCallView = true
|
||||
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// LocalAuthenticationUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Efim Poberezkin on 26.05.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
enum LAResult {
|
||||
case success
|
||||
case failed(authError: String?)
|
||||
case unavailable(authError: String?)
|
||||
}
|
||||
|
||||
func authenticate(reason: String, completed: @escaping (LAResult) -> Void) {
|
||||
let laContext = LAContext()
|
||||
var authAvailabilityError: NSError?
|
||||
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
|
||||
laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
||||
DispatchQueue.main.async {
|
||||
if success {
|
||||
completed(LAResult.success)
|
||||
} else {
|
||||
logger.error("authentication error: \(authError.debugDescription)")
|
||||
completed(LAResult.failed(authError: authError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("authentication availability error: \(authAvailabilityError.debugDescription)")
|
||||
completed(LAResult.unavailable(authError: authAvailabilityError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
func laTurnedOnAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "SimpleX Lock turned on",
|
||||
message: "You will be required to authenticate when you start or resume the app after 30 seconds in background."
|
||||
)
|
||||
}
|
||||
|
||||
func laFailedAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "Authentication failed",
|
||||
message: "You could not be verified; please try again."
|
||||
)
|
||||
}
|
||||
|
||||
func laUnavailableInstructionAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "Authentication unavailable",
|
||||
message: "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication."
|
||||
)
|
||||
}
|
||||
|
||||
func laUnavailableTurningOffAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "Authentication unavailable",
|
||||
message: "Device authentication is disabled. Turning off SimpleX Lock."
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// CallSettings.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 27/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CallSettings: View {
|
||||
@AppStorage(DEFAULT_WEBRTC_POLICY_RELAY) private var webrtcPolicyRelay = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section("Settings") {
|
||||
Toggle("Connect via relay", isOn: $webrtcPolicyRelay)
|
||||
}
|
||||
|
||||
Section("Limitations") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
textListItem("1.", "Do NOT use SimpleX for emergency calls.")
|
||||
textListItem("2.", "Pre-arrange the calls, as notifications arrive with a delay (we are improving it).")
|
||||
textListItem("3.", "The microphone does not work when the app is in the background.")
|
||||
textListItem("4.", "To prevent the call interruption, enable Do Not Disturb mode.")
|
||||
textListItem("5.", "If the video fails to connect, flip the camera to resolve it.")
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Text(n)
|
||||
Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CallSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CallSettings()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// ExperimentalFeaturesView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 30/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExperimentalFeaturesView: View {
|
||||
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("") {
|
||||
settingsRow("video") {
|
||||
Toggle("Audio & video calls", isOn: $enableCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExperimentalFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ExperimentalFeaturesView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// PrivacySettings.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 29/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacySettings: View {
|
||||
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
||||
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section("Device") {
|
||||
SimplexLockSetting()
|
||||
}
|
||||
Section("Chats") {
|
||||
settingsRow("photo") {
|
||||
Toggle("Auto-accept images", isOn: $autoAcceptImages)
|
||||
}
|
||||
settingsRow("network") {
|
||||
Toggle("Send link previews", isOn: $useLinkPreviews)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SimplexLockSetting: View {
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State var performLA: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var performLAToggleReset = false
|
||||
@State var laAlert: laSettingViewAlert? = nil
|
||||
|
||||
enum laSettingViewAlert: Identifiable {
|
||||
case laTurnedOnAlert
|
||||
case laFailedAlert
|
||||
case laUnavailableInstructionAlert
|
||||
case laUnavailableTurningOffAlert
|
||||
|
||||
var id: laSettingViewAlert { get { self } }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
settingsRow("lock") {
|
||||
Toggle("SimpleX Lock", isOn: $performLA)
|
||||
}
|
||||
.onChange(of: performLA) { performLAToggle in
|
||||
prefLANoticeShown = true
|
||||
if performLAToggleReset {
|
||||
performLAToggleReset = false
|
||||
} else {
|
||||
if performLAToggle {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $laAlert) { alertItem in
|
||||
switch alertItem {
|
||||
case .laTurnedOnAlert: return laTurnedOnAlert()
|
||||
case .laFailedAlert: return laFailedAlert()
|
||||
case .laUnavailableInstructionAlert: return laUnavailableInstructionAlert()
|
||||
case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func enableLA() {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
prefPerformLA = true
|
||||
laAlert = .laTurnedOnAlert
|
||||
case .failed:
|
||||
prefPerformLA = false
|
||||
withAnimation() {
|
||||
performLA = false
|
||||
}
|
||||
performLAToggleReset = true
|
||||
laAlert = .laFailedAlert
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
withAnimation() {
|
||||
performLA = false
|
||||
}
|
||||
performLAToggleReset = true
|
||||
laAlert = .laUnavailableInstructionAlert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func disableLA() {
|
||||
authenticate(reason: NSLocalizedString("Disable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
prefPerformLA = false
|
||||
case .failed:
|
||||
prefPerformLA = true
|
||||
withAnimation() {
|
||||
performLA = true
|
||||
}
|
||||
performLAToggleReset = true
|
||||
laAlert = .laFailedAlert
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
laAlert = .laUnavailableTurningOffAlert
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PrivacySettings()
|
||||
}
|
||||
}
|
||||
@@ -23,58 +23,72 @@ struct SMPServers: View {
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
var body: some View {
|
||||
return VStack(alignment: .leading) {
|
||||
Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle)
|
||||
.onChange(of: isUserSMPServersToggle) { _ in
|
||||
if (isUserSMPServersToggle) {
|
||||
isUserSMPServers = true
|
||||
} else {
|
||||
let servers = chatModel.userSMPServers ?? []
|
||||
if (!servers.isEmpty) {
|
||||
showResetServersAlert = true
|
||||
List {
|
||||
Section {
|
||||
Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle)
|
||||
.onChange(of: isUserSMPServersToggle) { _ in
|
||||
if (isUserSMPServersToggle) {
|
||||
isUserSMPServers = true
|
||||
} else {
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
.alert(isPresented: $showResetServersAlert) {
|
||||
Alert(
|
||||
title: Text("Use SimpleX Chat servers?"),
|
||||
message: Text("Saved SMP servers will be removed"),
|
||||
primaryButton: .destructive(Text("Confirm")) {
|
||||
saveSMPServers(smpServers: [])
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}, secondaryButton: .cancel() {
|
||||
withAnimation() {
|
||||
isUserSMPServersToggle = true
|
||||
let servers = chatModel.userSMPServers ?? []
|
||||
if (!servers.isEmpty) {
|
||||
showResetServersAlert = true
|
||||
} else {
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert(isPresented: $showResetServersAlert) {
|
||||
Alert(
|
||||
title: Text("Use SimpleX Chat servers?"),
|
||||
message: Text("Saved SMP servers will be removed"),
|
||||
primaryButton: .destructive(Text("Confirm")) {
|
||||
saveSMPServers(smpServers: [])
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr = ""
|
||||
}, secondaryButton: .cancel() {
|
||||
withAnimation() {
|
||||
isUserSMPServersToggle = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
if !isUserSMPServers {
|
||||
Text("Using SimpleX Chat servers.")
|
||||
}
|
||||
}
|
||||
|
||||
if !isUserSMPServers {
|
||||
Text("Using SimpleX Chat servers.")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Enter one SMP server per line:")
|
||||
if isUserSMPServers {
|
||||
Section {
|
||||
if editSMPServers {
|
||||
TextEditor(text: $userSMPServersStr)
|
||||
.focused($keyboardVisible)
|
||||
.font(serversFont)
|
||||
.disableAutocorrection(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.top, 2)
|
||||
.frame(height: 112)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
HStack(spacing: 20) {
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 160, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(userSMPServersStr)
|
||||
.font(serversFont)
|
||||
.frame(minHeight: 0, alignment: .topLeading)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(height: 160)
|
||||
}
|
||||
} header: {
|
||||
Text("SMP servers (one per line)")
|
||||
} footer: {
|
||||
HStack(spacing: 20) {
|
||||
if editSMPServers {
|
||||
Button("Cancel") {
|
||||
initialize()
|
||||
}
|
||||
@@ -86,22 +100,7 @@ struct SMPServers: View {
|
||||
}
|
||||
Spacer()
|
||||
howToButton()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(userSMPServersStr)
|
||||
.font(serversFont)
|
||||
.padding(10)
|
||||
.frame(minHeight: 0, alignment: .topLeading)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(height: 160)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
HStack {
|
||||
} else {
|
||||
Button("Edit") {
|
||||
editSMPServers = true
|
||||
}
|
||||
@@ -109,12 +108,10 @@ struct SMPServers: View {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.onAppear { initialize() }
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,27 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS
|
||||
|
||||
let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
|
||||
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
|
||||
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
|
||||
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
|
||||
let DEFAULT_USE_NOTIFICATIONS = "useNotifications"
|
||||
let DEFAULT_PENDING_CONNECTIONS = "pendingConnections"
|
||||
let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
|
||||
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
|
||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
|
||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
DEFAULT_LA_NOTICE_SHOWN: false,
|
||||
DEFAULT_PERFORM_LA: false,
|
||||
DEFAULT_USE_NOTIFICATIONS: false,
|
||||
DEFAULT_PENDING_CONNECTIONS: true,
|
||||
DEFAULT_WEBRTC_POLICY_RELAY: true,
|
||||
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
|
||||
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false
|
||||
]
|
||||
|
||||
private var indent: CGFloat = 36
|
||||
|
||||
@@ -25,6 +44,7 @@ struct SettingsView: View {
|
||||
@Binding var showSettings: Bool
|
||||
@AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false
|
||||
@AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true
|
||||
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
|
||||
@State var showNotificationsAlert: Bool = false
|
||||
@State var whichNotificationsAlert = NotificationAlert.enable
|
||||
|
||||
@@ -38,18 +58,7 @@ struct SettingsView: View {
|
||||
UserProfile()
|
||||
.navigationTitle("Your chat profile")
|
||||
} label: {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding(.trailing, 6)
|
||||
.padding(.vertical, 6)
|
||||
VStack(alignment: .leading) {
|
||||
Text(user.displayName)
|
||||
.fontWeight(.bold)
|
||||
.font(.title2)
|
||||
Text(user.fullName)
|
||||
}
|
||||
}
|
||||
ProfilePreview(profileOf: user)
|
||||
.padding(.leading, -8)
|
||||
}
|
||||
NavigationLink {
|
||||
@@ -61,6 +70,20 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
if enableCalls {
|
||||
NavigationLink {
|
||||
CallSettings()
|
||||
.navigationTitle("Your calls")
|
||||
} label: {
|
||||
settingsRow("video") { Text("Audio & video calls") }
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
PrivacySettings()
|
||||
.navigationTitle("Your privacy")
|
||||
} label: {
|
||||
settingsRow("lock") { Text("Privacy & security") }
|
||||
}
|
||||
settingsRow("link") {
|
||||
Toggle("Show pending connections", isOn: $pendingConnections)
|
||||
}
|
||||
@@ -117,34 +140,29 @@ struct SettingsView: View {
|
||||
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, indent)
|
||||
}
|
||||
NavigationLink {
|
||||
ExperimentalFeaturesView()
|
||||
.navigationTitle("Experimental features")
|
||||
} label: {
|
||||
settingsRow("gauge") { Text("Experimental features") }
|
||||
}
|
||||
// if let token = chatModel.deviceToken {
|
||||
// HStack {
|
||||
// notificationsIcon()
|
||||
// notificationsToggle(token)
|
||||
// }
|
||||
// }
|
||||
// NavigationLink {
|
||||
// CallViewDebug()
|
||||
// .frame(maxHeight: .infinity, alignment: .top)
|
||||
// } label: {
|
||||
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
|
||||
// }
|
||||
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Your settings")
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsRow<Content : View>(_ icon: String, content: @escaping () -> Content) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
content().padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationAlert {
|
||||
case enable
|
||||
case error(LocalizedStringKey, String)
|
||||
@@ -242,6 +260,33 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func settingsRow<Content : View>(_ icon: String, content: @escaping () -> Content) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary)
|
||||
content().padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfilePreview: View {
|
||||
var profileOf: NamedChat
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ProfileImage(imageStr: profileOf.image, color: color)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding(.trailing, 6)
|
||||
.padding(.vertical, 6)
|
||||
VStack(alignment: .leading) {
|
||||
Text(profileOf.displayName)
|
||||
.fontWeight(.bold)
|
||||
.font(.title2)
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
<target> (can be copied)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" wants to connect with you via " xml:space="preserve">
|
||||
<source> wants to connect with you via </source>
|
||||
<target> wants to connect with you via </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="!1 colored!" xml:space="preserve">
|
||||
<source>!1 colored!</source>
|
||||
<target>!1 colored!</target>
|
||||
@@ -50,6 +45,11 @@
|
||||
<target>%@ wants to connect!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d skipped message(s)</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld" xml:space="preserve">
|
||||
<source>%lld</source>
|
||||
<target>%lld</target>
|
||||
@@ -90,14 +90,14 @@
|
||||
<target>**Scan QR code**: to connect to your contact in person or via video call.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call.</source>
|
||||
<target>**e2e encrypted** audio call.</target>
|
||||
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call</source>
|
||||
<target>**e2e encrypted** audio call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
|
||||
<source>**e2e encrypted** video call.</source>
|
||||
<target>**e2e encrypted** video call.</target>
|
||||
<trans-unit id="**e2e encrypted** video call" xml:space="preserve">
|
||||
<source>**e2e encrypted** video call</source>
|
||||
<target>**e2e encrypted** video call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="*bold*" xml:space="preserve">
|
||||
@@ -133,7 +133,8 @@
|
||||
<trans-unit id="Accept" xml:space="preserve">
|
||||
<source>Accept</source>
|
||||
<target>Accept</target>
|
||||
<note>accept contact request via notification</note>
|
||||
<note>accept contact request via notification
|
||||
accept incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept contact" xml:space="preserve">
|
||||
<source>Accept contact</source>
|
||||
@@ -165,11 +166,6 @@
|
||||
<target>Already connected?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Answer" xml:space="preserve">
|
||||
<source>Answer</source>
|
||||
<target>Answer</target>
|
||||
<note>accept incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Answer call" xml:space="preserve">
|
||||
<source>Answer call</source>
|
||||
<target>Answer call</target>
|
||||
@@ -180,6 +176,26 @@
|
||||
<target>Attach</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Audio & video calls" xml:space="preserve">
|
||||
<source>Audio & video calls</source>
|
||||
<target>Audio & video calls</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Authentication failed" xml:space="preserve">
|
||||
<source>Authentication failed</source>
|
||||
<target>Authentication failed</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Authentication unavailable" xml:space="preserve">
|
||||
<source>Authentication unavailable</source>
|
||||
<target>Authentication unavailable</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept images" xml:space="preserve">
|
||||
<source>Auto-accept images</source>
|
||||
<target>Auto-accept images</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||
<source>Call already ended!</source>
|
||||
<target>Call already ended!</target>
|
||||
@@ -190,11 +206,6 @@
|
||||
<target>Cancel</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Capabilities" xml:space="preserve">
|
||||
<source>Capabilities</source>
|
||||
<target>Capabilities</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat console" xml:space="preserve">
|
||||
<source>Chat console</source>
|
||||
<target>Chat console</target>
|
||||
@@ -221,8 +232,8 @@
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Choose file" xml:space="preserve">
|
||||
<source>Choose file (new in v2.0)</source>
|
||||
<target>Choose file (new in v2.0)</target>
|
||||
<source>Choose file</source>
|
||||
<target>Choose file</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Choose from library" xml:space="preserve">
|
||||
@@ -275,6 +286,11 @@
|
||||
<target>Connect via one-time link?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connect via relay" xml:space="preserve">
|
||||
<source>Connect via relay</source>
|
||||
<target>Connect via relay</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connect with the developers" xml:space="preserve">
|
||||
<source>Connect with the developers</source>
|
||||
<target>Connect with the developers</target>
|
||||
@@ -445,31 +461,51 @@
|
||||
<target>Develop</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device" xml:space="preserve">
|
||||
<source>Device</source>
|
||||
<target>Device</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device authentication is disabled. Turning off SimpleX Lock." xml:space="preserve">
|
||||
<source>Device authentication is disabled. Turning off SimpleX Lock.</source>
|
||||
<target>Device authentication is disabled. Turning off SimpleX Lock.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." xml:space="preserve">
|
||||
<source>Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</source>
|
||||
<target>Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
<source>Disable SimpleX Lock</source>
|
||||
<target>Disable SimpleX Lock</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Display name" xml:space="preserve">
|
||||
<source>Display name</source>
|
||||
<target>Display name</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Do NOT use SimpleX for emergency calls." xml:space="preserve">
|
||||
<source>Do NOT use SimpleX for emergency calls.</source>
|
||||
<target>Do NOT use SimpleX for emergency calls.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Edit" xml:space="preserve">
|
||||
<source>Edit</source>
|
||||
<target>Edit</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enable SimpleX Lock" xml:space="preserve">
|
||||
<source>Enable SimpleX Lock</source>
|
||||
<target>Enable SimpleX Lock</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enable notifications? (BETA)" xml:space="preserve">
|
||||
<source>Enable notifications? (BETA)</source>
|
||||
<target>Enable notifications? (BETA)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="End" xml:space="preserve">
|
||||
<source>End</source>
|
||||
<target>End</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enter one SMP server per line:" xml:space="preserve">
|
||||
<source>Enter one SMP server per line:</source>
|
||||
<target>Enter one SMP server per line:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error deleting token" xml:space="preserve">
|
||||
<source>Error deleting token</source>
|
||||
<target>Error deleting token</target>
|
||||
@@ -495,6 +531,11 @@
|
||||
<target>Error: URL is invalid</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Experimental features" xml:space="preserve">
|
||||
<source>Experimental features</source>
|
||||
<target>Experimental features</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="File will be received when your contact is online, please wait or check later!" xml:space="preserve">
|
||||
<source>File will be received when your contact is online, please wait or check later!</source>
|
||||
<target>File will be received when your contact is online, please wait or check later!</target>
|
||||
@@ -540,9 +581,9 @@
|
||||
<target>How to use markdown</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ICE" xml:space="preserve">
|
||||
<source>ICE</source>
|
||||
<target>ICE</target>
|
||||
<trans-unit id="If the video fails to connect, flip the camera to resolve it." xml:space="preserve">
|
||||
<source>If the video fails to connect, flip the camera to resolve it.</source>
|
||||
<target>If the video fails to connect, flip the camera to resolve it.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="If you can't meet in person, **show QR code in the video call**, or share the link." xml:space="preserve">
|
||||
@@ -558,7 +599,7 @@
|
||||
<trans-unit id="Ignore" xml:space="preserve">
|
||||
<source>Ignore</source>
|
||||
<target>Ignore</target>
|
||||
<note>ignore incoming call via notification</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Image will be received when your contact is online, please wait or check later!" xml:space="preserve">
|
||||
<source>Image will be received when your contact is online, please wait or check later!</source>
|
||||
@@ -600,6 +641,21 @@
|
||||
<target>Invalid connection link</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It can happen when: 1. The messages expire on the server if they were not received for 30 days, 2. The server you use to receive the messages from this contact was updated and restarted. 3. The connection is compromised. Please connect to the developers via Settings to receive the updates about the servers. We will be adding server redundancy to prevent lost messages." xml:space="preserve">
|
||||
<source>It can happen when:
|
||||
1. The messages expire on the server if they were not received for 30 days,
|
||||
2. The server you use to receive the messages from this contact was updated and restarted.
|
||||
3. The connection is compromised.
|
||||
Please connect to the developers via Settings to receive the updates about the servers.
|
||||
We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>It can happen when:
|
||||
1. The messages expire on the server if they were not received for 30 days,
|
||||
2. The server you use to receive the messages from this contact was updated and restarted.
|
||||
3. The connection is compromised.
|
||||
Please connect to the developers via Settings to receive the updates about the servers.
|
||||
We will be adding server redundancy to prevent lost messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It seems like you are already connected via this link. If it is not the case, there was an error (%@)." xml:space="preserve">
|
||||
<source>It seems like you are already connected via this link. If it is not the case, there was an error (%@).</source>
|
||||
<target>It seems like you are already connected via this link. If it is not the case, there was an error (%@).</target>
|
||||
@@ -615,6 +671,11 @@
|
||||
<target>Large file!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Limitations" xml:space="preserve">
|
||||
<source>Limitations</source>
|
||||
<target>Limitations</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Make a private connection" xml:space="preserve">
|
||||
<source>Make a private connection</source>
|
||||
<target>Make a private connection</target>
|
||||
@@ -690,6 +751,11 @@
|
||||
<target>Paste</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Paste image" xml:space="preserve">
|
||||
<source>Paste image</source>
|
||||
<target>Paste image</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Paste received link" xml:space="preserve">
|
||||
<source>Paste received link</source>
|
||||
<target>Paste received link</target>
|
||||
@@ -720,6 +786,16 @@
|
||||
<target>Please check your network connection and try again.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." xml:space="preserve">
|
||||
<source>Pre-arrange the calls, as notifications arrive with a delay (we are improving it).</source>
|
||||
<target>Pre-arrange the calls, as notifications arrive with a delay (we are improving it).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||
<source>Privacy & security</source>
|
||||
<target>Privacy & security</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy redefined" xml:space="preserve">
|
||||
<source>Privacy redefined</source>
|
||||
<target>Privacy redefined</target>
|
||||
@@ -748,7 +824,7 @@
|
||||
<trans-unit id="Reject" xml:space="preserve">
|
||||
<source>Reject</source>
|
||||
<target>Reject</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>reject incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reject contact (sender NOT notified)" xml:space="preserve">
|
||||
<source>Reject contact (sender NOT notified)</source>
|
||||
@@ -765,11 +841,21 @@
|
||||
<target>Reply</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Retry" xml:space="preserve">
|
||||
<source>Retry</source>
|
||||
<target>Retry</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SMP servers" xml:space="preserve">
|
||||
<source>SMP servers</source>
|
||||
<target>SMP servers</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SMP servers (one per line)" xml:space="preserve">
|
||||
<source>SMP servers (one per line)</source>
|
||||
<target>SMP servers (one per line)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save" xml:space="preserve">
|
||||
<source>Save</source>
|
||||
<target>Save</target>
|
||||
@@ -795,9 +881,9 @@
|
||||
<target>Scan contact's QR code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send" xml:space="preserve">
|
||||
<source>Send</source>
|
||||
<target>Send</target>
|
||||
<trans-unit id="Send link previews" xml:space="preserve">
|
||||
<source>Send link previews</source>
|
||||
<target>Send link previews</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server connected" xml:space="preserve">
|
||||
@@ -830,9 +916,19 @@
|
||||
<target>Show pending connections</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Start" xml:space="preserve">
|
||||
<source>Start</source>
|
||||
<target>Start</target>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
<source>SimpleX Lock</source>
|
||||
<target>SimpleX Lock</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock turned on" xml:space="preserve">
|
||||
<source>SimpleX Lock turned on</source>
|
||||
<target>SimpleX Lock turned on</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Skipped messages" xml:space="preserve">
|
||||
<source>Skipped messages</source>
|
||||
<target>Skipped messages</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Take picture" xml:space="preserve">
|
||||
@@ -877,6 +973,11 @@
|
||||
<target>The contact you shared this link with will NOT be able to connect!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The microphone does not work when the app is in the background." xml:space="preserve">
|
||||
<source>The microphone does not work when the app is in the background.</source>
|
||||
<target>The microphone does not work when the app is in the background.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<target>The next generation of private messaging</target>
|
||||
@@ -912,11 +1013,23 @@
|
||||
<target>To make your first private connection, choose **one of the following**:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To prevent the call interruption, enable Do Not Disturb mode." xml:space="preserve">
|
||||
<source>To prevent the call interruption, enable Do Not Disturb mode.</source>
|
||||
<target>To prevent the call interruption, enable Do Not Disturb mode.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<target>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect your information, turn on SimpleX Lock. You will be prompted to complete authentication before this feature is enabled." xml:space="preserve">
|
||||
<source>To protect your information, turn on SimpleX Lock.
|
||||
You will be prompted to complete authentication before this feature is enabled.</source>
|
||||
<target>To protect your information, turn on SimpleX Lock.
|
||||
You will be prompted to complete authentication before this feature is enabled.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Trying to connect to the server used to receive messages from this contact (error: %@)." xml:space="preserve">
|
||||
<source>Trying to connect to the server used to receive messages from this contact (error: %@).</source>
|
||||
<target>Trying to connect to the server used to receive messages from this contact (error: %@).</target>
|
||||
@@ -927,6 +1040,11 @@
|
||||
<target>Trying to connect to the server used to receive messages from this contact.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Turn on" xml:space="preserve">
|
||||
<source>Turn on</source>
|
||||
<target>Turn on</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Unexpected error: %@" xml:space="preserve">
|
||||
<source>Unexpected error: %@</source>
|
||||
<target>Unexpected error: %@</target>
|
||||
@@ -939,6 +1057,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
To connect, please ask your contact to create another connection link and check that you have a stable network connection.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Unlock" xml:space="preserve">
|
||||
<source>Unlock</source>
|
||||
<target>Unlock</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Use SimpleX Chat servers?" xml:space="preserve">
|
||||
<source>Use SimpleX Chat servers?</source>
|
||||
<target>Use SimpleX Chat servers?</target>
|
||||
@@ -1009,6 +1132,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<target>You could not be verified; please try again.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You invited your contact" xml:space="preserve">
|
||||
<source>You invited your contact</source>
|
||||
<target>You invited your contact</target>
|
||||
@@ -1024,6 +1152,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>You will be connected when your contact's device is online, please wait or check later!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You will be required to authenticate when you start or resume the app after 30 seconds in background." xml:space="preserve">
|
||||
<source>You will be required to authenticate when you start or resume the app after 30 seconds in background.</source>
|
||||
<target>You will be required to authenticate when you start or resume the app after 30 seconds in background.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your SMP servers" xml:space="preserve">
|
||||
<source>Your SMP servers</source>
|
||||
<target>Your SMP servers</target>
|
||||
@@ -1034,6 +1167,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Your SimpleX contact address</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your calls" xml:space="preserve">
|
||||
<source>Your calls</source>
|
||||
<target>Your calls</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your chat address" xml:space="preserve">
|
||||
<source>Your chat address</source>
|
||||
<target>Your chat address</target>
|
||||
@@ -1071,6 +1209,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
||||
<target>Your contact sent a file that is larger than currently supported maximum size (%@).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your privacy" xml:space="preserve">
|
||||
<source>Your privacy</source>
|
||||
<target>Your privacy</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve">
|
||||
<source>Your profile is stored on your device and shared only with your contacts.
|
||||
SimpleX servers cannot see your profile.</source>
|
||||
@@ -1113,21 +1256,41 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>above, then choose:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="accepted" xml:space="preserve">
|
||||
<source>accepted</source>
|
||||
<target>accepted</target>
|
||||
<trans-unit id="accepted call" xml:space="preserve">
|
||||
<source>accepted call</source>
|
||||
<target>accepted call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted).</source>
|
||||
<target>audio call (not e2e encrypted).</target>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>audio call (not e2e encrypted)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message ID" xml:space="preserve">
|
||||
<source>bad message ID</source>
|
||||
<target>bad message ID</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message hash" xml:space="preserve">
|
||||
<source>bad message hash</source>
|
||||
<target>bad message hash</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bold" xml:space="preserve">
|
||||
<source>bold</source>
|
||||
<target>bold</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call error" xml:space="preserve">
|
||||
<source>call error</source>
|
||||
<target>call error</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call in progress" xml:space="preserve">
|
||||
<source>call in progress</source>
|
||||
<target>call in progress</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="calling…" xml:space="preserve">
|
||||
<source>calling…</source>
|
||||
<target>calling…</target>
|
||||
@@ -1148,11 +1311,15 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>connected</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting call…" xml:space="preserve">
|
||||
<source>connecting call…</source>
|
||||
<target>connecting call…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting…" xml:space="preserve">
|
||||
<source>connecting…</source>
|
||||
<target>connecting…</target>
|
||||
<note>call status
|
||||
chat list item title</note>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connection established" xml:space="preserve">
|
||||
<source>connection established</source>
|
||||
@@ -1179,24 +1346,24 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>deleted</target>
|
||||
<note>deleted chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="duplicate message" xml:space="preserve">
|
||||
<source>duplicate message</source>
|
||||
<target>duplicate message</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="e2e encrypted" xml:space="preserve">
|
||||
<source>e2e encrypted</source>
|
||||
<target>e2e encrypted</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ended %@" xml:space="preserve">
|
||||
<source>ended %@</source>
|
||||
<target>ended %@</target>
|
||||
<note>call status</note>
|
||||
<trans-unit id="ended" xml:space="preserve">
|
||||
<source>ended</source>
|
||||
<target>ended</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="error" xml:space="preserve">
|
||||
<source>error</source>
|
||||
<target>error</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="in progress" xml:space="preserve">
|
||||
<source>in progress</source>
|
||||
<target>in progress</target>
|
||||
<trans-unit id="ended call %@" xml:space="preserve">
|
||||
<source>ended call %@</source>
|
||||
<target>ended call %@</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="invited to connect" xml:space="preserve">
|
||||
@@ -1209,9 +1376,9 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>italic</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="missed" xml:space="preserve">
|
||||
<source>missed</source>
|
||||
<target>missed</target>
|
||||
<trans-unit id="missed call" xml:space="preserve">
|
||||
<source>missed call</source>
|
||||
<target>missed call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
@@ -1234,9 +1401,14 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>received answer…</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected" xml:space="preserve">
|
||||
<source>rejected</source>
|
||||
<target>rejected</target>
|
||||
<trans-unit id="received confirmation…" xml:space="preserve">
|
||||
<source>received confirmation…</source>
|
||||
<target>received confirmation…</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected call" xml:space="preserve">
|
||||
<source>rejected call</source>
|
||||
<target>rejected call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="secret" xml:space="preserve">
|
||||
@@ -1279,9 +1451,9 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>via relay</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>video call (not e2e encrypted).</source>
|
||||
<target>video call (not e2e encrypted).</target>
|
||||
<trans-unit id="video call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>video call (not e2e encrypted)</source>
|
||||
<target>video call (not e2e encrypted)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="waiting for answer…" xml:space="preserve">
|
||||
@@ -1299,11 +1471,6 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>wants to connect to you!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="with e2e encryption" xml:space="preserve">
|
||||
<source>with e2e encryption</source>
|
||||
<target>with e2e encryption</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you shared one-time link" xml:space="preserve">
|
||||
<source>you shared one-time link</source>
|
||||
<target>you shared one-time link</target>
|
||||
@@ -1331,6 +1498,11 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>SimpleX needs camera access to scan QR codes to connect to other users and for video calls.</target>
|
||||
<note>Privacy - Camera Usage Description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSFaceIDUsageDescription" xml:space="preserve">
|
||||
<source>SimpleX uses Face ID for local authentication</source>
|
||||
<target>SimpleX uses Face ID for local authentication</target>
|
||||
<note>Privacy - Face ID Usage Description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSMicrophoneUsageDescription" xml:space="preserve">
|
||||
<source>SimpleX needs microphone access for audio and video calls.</source>
|
||||
<target>SimpleX needs microphone access for audio and video calls.</target>
|
||||
@@ -1380,14 +1552,19 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>%@ wants to connect!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call.</source>
|
||||
<target>**e2e encrypted** audio call.</target>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d skipped message(s)</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call</source>
|
||||
<target>**e2e encrypted** audio call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
|
||||
<source>**e2e encrypted** video call.</source>
|
||||
<target>**e2e encrypted** video call.</target>
|
||||
<trans-unit id="**e2e encrypted** video call" xml:space="preserve">
|
||||
<source>**e2e encrypted** video call</source>
|
||||
<target>**e2e encrypted** video call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept contact request from %@?" xml:space="preserve">
|
||||
@@ -1410,26 +1587,50 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>You can now send messages to %@</target>
|
||||
<note>notification body</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="accepted" xml:space="preserve">
|
||||
<source>accepted</source>
|
||||
<target>accepted</target>
|
||||
<trans-unit id="accepted call" xml:space="preserve">
|
||||
<source>accepted call</source>
|
||||
<target>accepted call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted).</source>
|
||||
<target>audio call (not e2e encrypted).</target>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>audio call (not e2e encrypted)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message ID" xml:space="preserve">
|
||||
<source>bad message ID</source>
|
||||
<target>bad message ID</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message hash" xml:space="preserve">
|
||||
<source>bad message hash</source>
|
||||
<target>bad message hash</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call error" xml:space="preserve">
|
||||
<source>call error</source>
|
||||
<target>call error</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call in progress" xml:space="preserve">
|
||||
<source>call in progress</source>
|
||||
<target>call in progress</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="calling…" xml:space="preserve">
|
||||
<source>calling…</source>
|
||||
<target>calling…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting call…" xml:space="preserve">
|
||||
<source>connecting call…</source>
|
||||
<target>connecting call…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting…" xml:space="preserve">
|
||||
<source>connecting…</source>
|
||||
<target>connecting…</target>
|
||||
<note>call status
|
||||
chat list item title</note>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connection established" xml:space="preserve">
|
||||
<source>connection established</source>
|
||||
@@ -1446,19 +1647,14 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>deleted</target>
|
||||
<note>deleted chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ended %@" xml:space="preserve">
|
||||
<source>ended %@</source>
|
||||
<target>ended %@</target>
|
||||
<note>call status</note>
|
||||
<trans-unit id="duplicate message" xml:space="preserve">
|
||||
<source>duplicate message</source>
|
||||
<target>duplicate message</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="error" xml:space="preserve">
|
||||
<source>error</source>
|
||||
<target>error</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="in progress" xml:space="preserve">
|
||||
<source>in progress</source>
|
||||
<target>in progress</target>
|
||||
<trans-unit id="ended call %@" xml:space="preserve">
|
||||
<source>ended call %@</source>
|
||||
<target>ended call %@</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="invited to connect" xml:space="preserve">
|
||||
@@ -1466,19 +1662,14 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>invited to connect</target>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="missed" xml:space="preserve">
|
||||
<source>missed</source>
|
||||
<target>missed</target>
|
||||
<trans-unit id="missed call" xml:space="preserve">
|
||||
<source>missed call</source>
|
||||
<target>missed call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
<source>no e2e encryption</source>
|
||||
<target>no e2e encryption</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected" xml:space="preserve">
|
||||
<source>rejected</source>
|
||||
<target>rejected</target>
|
||||
<trans-unit id="rejected call" xml:space="preserve">
|
||||
<source>rejected call</source>
|
||||
<target>rejected call</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="via contact address link" xml:space="preserve">
|
||||
@@ -1491,14 +1682,9 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>via one-time link</target>
|
||||
<note>chat list item description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>video call (not e2e encrypted).</source>
|
||||
<target>video call (not e2e encrypted).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="with e2e encryption" xml:space="preserve">
|
||||
<source>with e2e encryption</source>
|
||||
<target>with e2e encryption</target>
|
||||
<trans-unit id="video call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>video call (not e2e encrypted)</source>
|
||||
<target>video call (not e2e encrypted)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you shared one-time link" xml:space="preserve">
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,3 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Choose file" = "Choose file (new in v2.0)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Connecting to server…";
|
||||
|
||||
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
"CFBundleName" = "SimpleX";
|
||||
/* Privacy - Camera Usage Description */
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
<target> (можно скопировать)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" wants to connect with you via " xml:space="preserve">
|
||||
<source> wants to connect with you via </source>
|
||||
<target> хочет связаться с вами через </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="!1 colored!" xml:space="preserve">
|
||||
<source>!1 colored!</source>
|
||||
<target>!1 цвет!</target>
|
||||
@@ -50,6 +45,11 @@
|
||||
<target>%@ хочет соединиться!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d пропущенных сообщение(й)</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld" xml:space="preserve">
|
||||
<source>%lld</source>
|
||||
<target>%lld</target>
|
||||
@@ -90,14 +90,14 @@
|
||||
<target>**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call.</source>
|
||||
<target>**e2e зашифрованный** аудиозвонок.</target>
|
||||
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call</source>
|
||||
<target>**e2e зашифрованный** аудиозвонок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
|
||||
<source>**e2e encrypted** video call.</source>
|
||||
<target>**e2e зашифрованный** видеозвонок.</target>
|
||||
<trans-unit id="**e2e encrypted** video call" xml:space="preserve">
|
||||
<source>**e2e encrypted** video call</source>
|
||||
<target>**e2e зашифрованный** видеозвонок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="*bold*" xml:space="preserve">
|
||||
@@ -133,7 +133,8 @@
|
||||
<trans-unit id="Accept" xml:space="preserve">
|
||||
<source>Accept</source>
|
||||
<target>Принять</target>
|
||||
<note>accept contact request via notification</note>
|
||||
<note>accept contact request via notification
|
||||
accept incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept contact" xml:space="preserve">
|
||||
<source>Accept contact</source>
|
||||
@@ -165,11 +166,6 @@
|
||||
<target>Соединение уже установлено?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Answer" xml:space="preserve">
|
||||
<source>Answer</source>
|
||||
<target>Ответить</target>
|
||||
<note>accept incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Answer call" xml:space="preserve">
|
||||
<source>Answer call</source>
|
||||
<target>Принять звонок</target>
|
||||
@@ -180,6 +176,26 @@
|
||||
<target>Прикрепить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Audio & video calls" xml:space="preserve">
|
||||
<source>Audio & video calls</source>
|
||||
<target>Аудио- и видеозвонки</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Authentication failed" xml:space="preserve">
|
||||
<source>Authentication failed</source>
|
||||
<target>Ошибка аутентификации</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Authentication unavailable" xml:space="preserve">
|
||||
<source>Authentication unavailable</source>
|
||||
<target>Аутентификация недоступна</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept images" xml:space="preserve">
|
||||
<source>Auto-accept images</source>
|
||||
<target>Автоприем изображений</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||
<source>Call already ended!</source>
|
||||
<target>Звонок уже завершен!</target>
|
||||
@@ -190,11 +206,6 @@
|
||||
<target>Отменить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Capabilities" xml:space="preserve">
|
||||
<source>Capabilities</source>
|
||||
<target>Возможности</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat console" xml:space="preserve">
|
||||
<source>Chat console</source>
|
||||
<target>Консоль</target>
|
||||
@@ -207,7 +218,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="Chats" xml:space="preserve">
|
||||
<source>Chats</source>
|
||||
<target>Назад</target>
|
||||
<target>Чаты</target>
|
||||
<note>back button to return to chats list</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Check messages" xml:space="preserve">
|
||||
@@ -221,8 +232,8 @@
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Choose file" xml:space="preserve">
|
||||
<source>Choose file (new in v2.0)</source>
|
||||
<target>Выбрать файл (v2.0)</target>
|
||||
<source>Choose file</source>
|
||||
<target>Выбрать файл</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Choose from library" xml:space="preserve">
|
||||
@@ -275,6 +286,11 @@
|
||||
<target>Соединиться через одноразовую ссылку?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connect via relay" xml:space="preserve">
|
||||
<source>Connect via relay</source>
|
||||
<target>Соединяться через сервер (relay)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connect with the developers" xml:space="preserve">
|
||||
<source>Connect with the developers</source>
|
||||
<target>Соединиться с разработчиками</target>
|
||||
@@ -445,31 +461,51 @@
|
||||
<target>Для разработчиков</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device" xml:space="preserve">
|
||||
<source>Device</source>
|
||||
<target>Устройство</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device authentication is disabled. Turning off SimpleX Lock." xml:space="preserve">
|
||||
<source>Device authentication is disabled. Turning off SimpleX Lock.</source>
|
||||
<target>Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." xml:space="preserve">
|
||||
<source>Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</source>
|
||||
<target>Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
<source>Disable SimpleX Lock</source>
|
||||
<target>Отключить блокировку SimpleX</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Display name" xml:space="preserve">
|
||||
<source>Display name</source>
|
||||
<target>Имя профиля</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Do NOT use SimpleX for emergency calls." xml:space="preserve">
|
||||
<source>Do NOT use SimpleX for emergency calls.</source>
|
||||
<target>Не используйте SimpleX для экстренных звонков</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Edit" xml:space="preserve">
|
||||
<source>Edit</source>
|
||||
<target>Редактировать</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enable SimpleX Lock" xml:space="preserve">
|
||||
<source>Enable SimpleX Lock</source>
|
||||
<target>Включить блокировку SimpleX</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enable notifications? (BETA)" xml:space="preserve">
|
||||
<source>Enable notifications? (BETA)</source>
|
||||
<target>Включить уведомления? (БЕТА)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="End" xml:space="preserve">
|
||||
<source>End</source>
|
||||
<target>Завершить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Enter one SMP server per line:" xml:space="preserve">
|
||||
<source>Enter one SMP server per line:</source>
|
||||
<target>Введите SMP серверы, каждый на отдельной строке:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error deleting token" xml:space="preserve">
|
||||
<source>Error deleting token</source>
|
||||
<target>Ошибка удаления токена</target>
|
||||
@@ -495,6 +531,11 @@
|
||||
<target>Ошибка: неверная ссылка</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Experimental features" xml:space="preserve">
|
||||
<source>Experimental features</source>
|
||||
<target>Экспериментальные функции</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="File will be received when your contact is online, please wait or check later!" xml:space="preserve">
|
||||
<source>File will be received when your contact is online, please wait or check later!</source>
|
||||
<target>Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!</target>
|
||||
@@ -540,9 +581,9 @@
|
||||
<target>Как форматировать</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ICE" xml:space="preserve">
|
||||
<source>ICE</source>
|
||||
<target>ICE</target>
|
||||
<trans-unit id="If the video fails to connect, flip the camera to resolve it." xml:space="preserve">
|
||||
<source>If the video fails to connect, flip the camera to resolve it.</source>
|
||||
<target>Если видео не соединилось, переключите камеру.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="If you can't meet in person, **show QR code in the video call**, or share the link." xml:space="preserve">
|
||||
@@ -558,7 +599,7 @@
|
||||
<trans-unit id="Ignore" xml:space="preserve">
|
||||
<source>Ignore</source>
|
||||
<target>Не отвечать</target>
|
||||
<note>ignore incoming call via notification</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Image will be received when your contact is online, please wait or check later!" xml:space="preserve">
|
||||
<source>Image will be received when your contact is online, please wait or check later!</source>
|
||||
@@ -600,6 +641,21 @@
|
||||
<target>Ошибка в ссылке контакта</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It can happen when: 1. The messages expire on the server if they were not received for 30 days, 2. The server you use to receive the messages from this contact was updated and restarted. 3. The connection is compromised. Please connect to the developers via Settings to receive the updates about the servers. We will be adding server redundancy to prevent lost messages." xml:space="preserve">
|
||||
<source>It can happen when:
|
||||
1. The messages expire on the server if they were not received for 30 days,
|
||||
2. The server you use to receive the messages from this contact was updated and restarted.
|
||||
3. The connection is compromised.
|
||||
Please connect to the developers via Settings to receive the updates about the servers.
|
||||
We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Это может случится, когда:
|
||||
1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.
|
||||
2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.
|
||||
3. Соединение компроментировано.
|
||||
Пожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.
|
||||
Мы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It seems like you are already connected via this link. If it is not the case, there was an error (%@)." xml:space="preserve">
|
||||
<source>It seems like you are already connected via this link. If it is not the case, there was an error (%@).</source>
|
||||
<target>Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@).</target>
|
||||
@@ -615,6 +671,11 @@
|
||||
<target>Большой файл!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Limitations" xml:space="preserve">
|
||||
<source>Limitations</source>
|
||||
<target>Ограничения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Make a private connection" xml:space="preserve">
|
||||
<source>Make a private connection</source>
|
||||
<target>Добавьте контакт</target>
|
||||
@@ -690,6 +751,11 @@
|
||||
<target>Вставить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Paste image" xml:space="preserve">
|
||||
<source>Paste image</source>
|
||||
<target>Вставить изображение</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Paste received link" xml:space="preserve">
|
||||
<source>Paste received link</source>
|
||||
<target>Вставить полученную ссылку</target>
|
||||
@@ -720,6 +786,16 @@
|
||||
<target>Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." xml:space="preserve">
|
||||
<source>Pre-arrange the calls, as notifications arrive with a delay (we are improving it).</source>
|
||||
<target>Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||
<source>Privacy & security</source>
|
||||
<target>Конфиденциальность</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy redefined" xml:space="preserve">
|
||||
<source>Privacy redefined</source>
|
||||
<target>Более конфиденциальный</target>
|
||||
@@ -748,7 +824,7 @@
|
||||
<trans-unit id="Reject" xml:space="preserve">
|
||||
<source>Reject</source>
|
||||
<target>Отклонить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>reject incoming call via notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reject contact (sender NOT notified)" xml:space="preserve">
|
||||
<source>Reject contact (sender NOT notified)</source>
|
||||
@@ -765,11 +841,21 @@
|
||||
<target>Ответить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Retry" xml:space="preserve">
|
||||
<source>Retry</source>
|
||||
<target>Повторить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SMP servers" xml:space="preserve">
|
||||
<source>SMP servers</source>
|
||||
<target>SMP серверы</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SMP servers (one per line)" xml:space="preserve">
|
||||
<source>SMP servers (one per line)</source>
|
||||
<target>SMP серверы (один на строке)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save" xml:space="preserve">
|
||||
<source>Save</source>
|
||||
<target>Сохранить</target>
|
||||
@@ -795,9 +881,9 @@
|
||||
<target>Сосканировать QR код контакта</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send" xml:space="preserve">
|
||||
<source>Send</source>
|
||||
<target>Отправить</target>
|
||||
<trans-unit id="Send link previews" xml:space="preserve">
|
||||
<source>Send link previews</source>
|
||||
<target>Отправлять картинки ссылок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server connected" xml:space="preserve">
|
||||
@@ -830,9 +916,19 @@
|
||||
<target>Показать ожидаемые соединения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Start" xml:space="preserve">
|
||||
<source>Start</source>
|
||||
<target>Начать</target>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
<source>SimpleX Lock</source>
|
||||
<target>Блокировка SimpleX</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock turned on" xml:space="preserve">
|
||||
<source>SimpleX Lock turned on</source>
|
||||
<target>Блокировка SimpleX включена</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Skipped messages" xml:space="preserve">
|
||||
<source>Skipped messages</source>
|
||||
<target>Пропущенные сообщения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Take picture" xml:space="preserve">
|
||||
@@ -877,6 +973,11 @@
|
||||
<target>Контакт, которому вы отправили эту ссылку, не сможет соединиться!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The microphone does not work when the app is in the background." xml:space="preserve">
|
||||
<source>The microphone does not work when the app is in the background.</source>
|
||||
<target>Микрофон не работает, когда приложение в фоновом режиме.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<target>Новое поколение приватных сообщений</target>
|
||||
@@ -912,11 +1013,23 @@
|
||||
<target>Чтобы добавить ваш первый контакт, выберите **одно из**:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To prevent the call interruption, enable Do Not Disturb mode." xml:space="preserve">
|
||||
<source>To prevent the call interruption, enable Do Not Disturb mode.</source>
|
||||
<target>Чтобы избежать прерывания звонков, включите режим Не Беспокоить.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<target>Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect your information, turn on SimpleX Lock. You will be prompted to complete authentication before this feature is enabled." xml:space="preserve">
|
||||
<source>To protect your information, turn on SimpleX Lock.
|
||||
You will be prompted to complete authentication before this feature is enabled.</source>
|
||||
<target>Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.
|
||||
Вам будет нужно пройти аутентификацию для включения блокировки.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Trying to connect to the server used to receive messages from this contact (error: %@)." xml:space="preserve">
|
||||
<source>Trying to connect to the server used to receive messages from this contact (error: %@).</source>
|
||||
<target>Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@).</target>
|
||||
@@ -927,6 +1040,11 @@
|
||||
<target>Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Turn on" xml:space="preserve">
|
||||
<source>Turn on</source>
|
||||
<target>Включить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Unexpected error: %@" xml:space="preserve">
|
||||
<source>Unexpected error: %@</source>
|
||||
<target>Неожиданная ошибка: %@</target>
|
||||
@@ -939,6 +1057,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
Чтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Unlock" xml:space="preserve">
|
||||
<source>Unlock</source>
|
||||
<target>Разблокировать</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Use SimpleX Chat servers?" xml:space="preserve">
|
||||
<source>Use SimpleX Chat servers?</source>
|
||||
<target>Использовать серверы предосталенные SimpleX Chat?</target>
|
||||
@@ -1009,6 +1132,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<target>Верификация не удалась; пожалуйста, попробуйте ещё раз.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You invited your contact" xml:space="preserve">
|
||||
<source>You invited your contact</source>
|
||||
<target>Вы пригласили ваш контакт</target>
|
||||
@@ -1024,6 +1152,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You will be required to authenticate when you start or resume the app after 30 seconds in background." xml:space="preserve">
|
||||
<source>You will be required to authenticate when you start or resume the app after 30 seconds in background.</source>
|
||||
<target>Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your SMP servers" xml:space="preserve">
|
||||
<source>Your SMP servers</source>
|
||||
<target>Ваши SMP серверы</target>
|
||||
@@ -1034,6 +1167,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Ваш SimpleX адрес</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your calls" xml:space="preserve">
|
||||
<source>Your calls</source>
|
||||
<target>Ваши звонки</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your chat address" xml:space="preserve">
|
||||
<source>Your chat address</source>
|
||||
<target>Ваш SimpleX адрес</target>
|
||||
@@ -1071,6 +1209,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
||||
<target>Ваш контакт отправил файл, размер которого превышает максимальный размер (%@).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your privacy" xml:space="preserve">
|
||||
<source>Your privacy</source>
|
||||
<target>Конфиденциальность</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve">
|
||||
<source>Your profile is stored on your device and shared only with your contacts.
|
||||
SimpleX servers cannot see your profile.</source>
|
||||
@@ -1113,21 +1256,41 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>наверху, затем выберите:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="accepted" xml:space="preserve">
|
||||
<source>accepted</source>
|
||||
<target>принятый звонок</target>
|
||||
<trans-unit id="accepted call" xml:space="preserve">
|
||||
<source>accepted call</source>
|
||||
<target> принятый звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted).</source>
|
||||
<target>аудиозвонок (не e2e зашифрованный).</target>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>аудиозвонок (не e2e зашифрованный)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message ID" xml:space="preserve">
|
||||
<source>bad message ID</source>
|
||||
<target>ошибка ID сообщения</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message hash" xml:space="preserve">
|
||||
<source>bad message hash</source>
|
||||
<target>ошибка хэш сообщения</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bold" xml:space="preserve">
|
||||
<source>bold</source>
|
||||
<target>жирный</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call error" xml:space="preserve">
|
||||
<source>call error</source>
|
||||
<target>ошибка звонка</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call in progress" xml:space="preserve">
|
||||
<source>call in progress</source>
|
||||
<target>активный звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="calling…" xml:space="preserve">
|
||||
<source>calling…</source>
|
||||
<target>входящий звонок…</target>
|
||||
@@ -1148,11 +1311,15 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>соединение установлено</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting call…" xml:space="preserve">
|
||||
<source>connecting call…</source>
|
||||
<target>звонок соединяется…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting…" xml:space="preserve">
|
||||
<source>connecting…</source>
|
||||
<target>соединяется…</target>
|
||||
<note>call status
|
||||
chat list item title</note>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connection established" xml:space="preserve">
|
||||
<source>connection established</source>
|
||||
@@ -1179,24 +1346,24 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>удалено</target>
|
||||
<note>deleted chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="duplicate message" xml:space="preserve">
|
||||
<source>duplicate message</source>
|
||||
<target>повторное сообщение</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="e2e encrypted" xml:space="preserve">
|
||||
<source>e2e encrypted</source>
|
||||
<target>e2e зашифровано</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ended %@" xml:space="preserve">
|
||||
<source>ended %@</source>
|
||||
<target>завершен %@</target>
|
||||
<note>call status</note>
|
||||
<trans-unit id="ended" xml:space="preserve">
|
||||
<source>ended</source>
|
||||
<target>завершён</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="error" xml:space="preserve">
|
||||
<source>error</source>
|
||||
<target>ошибка соединения</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="in progress" xml:space="preserve">
|
||||
<source>in progress</source>
|
||||
<target>активный звонок</target>
|
||||
<trans-unit id="ended call %@" xml:space="preserve">
|
||||
<source>ended call %@</source>
|
||||
<target>завершённый звонок %@</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="invited to connect" xml:space="preserve">
|
||||
@@ -1209,8 +1376,8 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>курсив</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="missed" xml:space="preserve">
|
||||
<source>missed</source>
|
||||
<trans-unit id="missed call" xml:space="preserve">
|
||||
<source>missed call</source>
|
||||
<target>пропущенный звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
@@ -1234,9 +1401,14 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>получен ответ…</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected" xml:space="preserve">
|
||||
<source>rejected</source>
|
||||
<target>отклоненный звонок</target>
|
||||
<trans-unit id="received confirmation…" xml:space="preserve">
|
||||
<source>received confirmation…</source>
|
||||
<target>получено подтверждение…</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected call" xml:space="preserve">
|
||||
<source>rejected call</source>
|
||||
<target>отклонённый звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="secret" xml:space="preserve">
|
||||
@@ -1279,9 +1451,9 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>через relay сервер</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>video call (not e2e encrypted).</source>
|
||||
<target>видеозвонок (не e2e зашифрованный).</target>
|
||||
<trans-unit id="video call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>video call (not e2e encrypted)</source>
|
||||
<target>видеозвонок (не e2e зашифрованный)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="waiting for answer…" xml:space="preserve">
|
||||
@@ -1299,11 +1471,6 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>хочет соединиться с вами!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="with e2e encryption" xml:space="preserve">
|
||||
<source>with e2e encryption</source>
|
||||
<target>e2e зашифровано</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you shared one-time link" xml:space="preserve">
|
||||
<source>you shared one-time link</source>
|
||||
<target>вы создали ссылку</target>
|
||||
@@ -1331,6 +1498,11 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков.</target>
|
||||
<note>Privacy - Camera Usage Description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSFaceIDUsageDescription" xml:space="preserve">
|
||||
<source>SimpleX uses Face ID for local authentication</source>
|
||||
<target>SimpleX использует Face ID для аутентификации</target>
|
||||
<note>Privacy - Face ID Usage Description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSMicrophoneUsageDescription" xml:space="preserve">
|
||||
<source>SimpleX needs microphone access for audio and video calls.</source>
|
||||
<target>SimpleX использует микрофон для аудио и видео звонков.</target>
|
||||
@@ -1380,14 +1552,19 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>%@ хочет соединиться!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call.</source>
|
||||
<target>**e2e зашифрованный** аудиозвонок.</target>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d пропущенных сообщений</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
|
||||
<source>**e2e encrypted** audio call</source>
|
||||
<target>**e2e зашифрованный** аудиозвонок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
|
||||
<source>**e2e encrypted** video call.</source>
|
||||
<target>**e2e зашифрованный** видеозвонок.</target>
|
||||
<trans-unit id="**e2e encrypted** video call" xml:space="preserve">
|
||||
<source>**e2e encrypted** video call</source>
|
||||
<target>**e2e зашифрованный** видеозвонок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept contact request from %@?" xml:space="preserve">
|
||||
@@ -1410,26 +1587,50 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>Вы можете отправлять сообщения %@</target>
|
||||
<note>notification body</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="accepted" xml:space="preserve">
|
||||
<source>accepted</source>
|
||||
<target>принятый звонок</target>
|
||||
<trans-unit id="accepted call" xml:space="preserve">
|
||||
<source>accepted call</source>
|
||||
<target>принятный звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted).</source>
|
||||
<target>аудиозвонок (не e2e зашифрованный).</target>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>аудиозвонок (не e2e зашифрованный)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message ID" xml:space="preserve">
|
||||
<source>bad message ID</source>
|
||||
<target>ошибка ID сообщения</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="bad message hash" xml:space="preserve">
|
||||
<source>bad message hash</source>
|
||||
<target>ошибка хэш сообщения</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call error" xml:space="preserve">
|
||||
<source>call error</source>
|
||||
<target>ошибка звонка</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="call in progress" xml:space="preserve">
|
||||
<source>call in progress</source>
|
||||
<target>активный звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="calling…" xml:space="preserve">
|
||||
<source>calling…</source>
|
||||
<target>входящий звонок…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting call…" xml:space="preserve">
|
||||
<source>connecting call…</source>
|
||||
<target>звонок соединяется…</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connecting…" xml:space="preserve">
|
||||
<source>connecting…</source>
|
||||
<target>соединяется…</target>
|
||||
<note>call status
|
||||
chat list item title</note>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="connection established" xml:space="preserve">
|
||||
<source>connection established</source>
|
||||
@@ -1446,19 +1647,14 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>удалено</target>
|
||||
<note>deleted chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ended %@" xml:space="preserve">
|
||||
<source>ended %@</source>
|
||||
<target>завершен %@</target>
|
||||
<note>call status</note>
|
||||
<trans-unit id="duplicate message" xml:space="preserve">
|
||||
<source>duplicate message</source>
|
||||
<target>повторное сообщение</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="error" xml:space="preserve">
|
||||
<source>error</source>
|
||||
<target>ошибка соединения</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="in progress" xml:space="preserve">
|
||||
<source>in progress</source>
|
||||
<target>активный звонок</target>
|
||||
<trans-unit id="ended call %@" xml:space="preserve">
|
||||
<source>ended call %@</source>
|
||||
<target>завершённый звонок %@</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="invited to connect" xml:space="preserve">
|
||||
@@ -1466,19 +1662,14 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>приглашение соединиться</target>
|
||||
<note>chat list item title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="missed" xml:space="preserve">
|
||||
<source>missed</source>
|
||||
<trans-unit id="missed call" xml:space="preserve">
|
||||
<source>missed call</source>
|
||||
<target>пропущенный звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
<source>no e2e encryption</source>
|
||||
<target>нет e2e шифрования</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="rejected" xml:space="preserve">
|
||||
<source>rejected</source>
|
||||
<target>отклоненный звонок</target>
|
||||
<trans-unit id="rejected call" xml:space="preserve">
|
||||
<source>rejected call</source>
|
||||
<target>отклонённый звонок</target>
|
||||
<note>call status</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="via contact address link" xml:space="preserve">
|
||||
@@ -1491,14 +1682,9 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>через одноразовую ссылку</target>
|
||||
<note>chat list item description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
|
||||
<source>video call (not e2e encrypted).</source>
|
||||
<target>видеозвонок (не e2e зашифрованный).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="with e2e encryption" xml:space="preserve">
|
||||
<source>with e2e encryption</source>
|
||||
<target>e2e зашифровано</target>
|
||||
<trans-unit id="video call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>video call (not e2e encrypted)</source>
|
||||
<target>видеозвонок (не e2e зашифрованный)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you shared one-time link" xml:space="preserve">
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,3 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Choose file" = "Choose file (new in v2.0)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Connecting to server…";
|
||||
|
||||
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
"CFBundleName" = "SimpleX";
|
||||
/* Privacy - Camera Usage Description */
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* No comment provided by engineer. */
|
||||
"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок.";
|
||||
"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок.";
|
||||
"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок";
|
||||
|
||||
/* notification title */
|
||||
"%@ is connected!" = "соединение с %@ установлено!";
|
||||
@@ -10,20 +10,37 @@
|
||||
/* notification title */
|
||||
"%@ wants to connect!" = "%@ хочет соединиться!";
|
||||
|
||||
/* integrity error chat item */
|
||||
"%d skipped message(s)" = "%d пропущенных сообщений";
|
||||
|
||||
/* notification body */
|
||||
"Accept contact request from %@?" = "Принять запрос на соединение от %@?";
|
||||
|
||||
/* call status */
|
||||
"accepted" = "принятый звонок";
|
||||
"accepted call" = "принятный звонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный).";
|
||||
"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)";
|
||||
|
||||
/* integrity error chat item */
|
||||
"bad message hash" = "ошибка хэш сообщения";
|
||||
|
||||
/* integrity error chat item */
|
||||
"bad message ID" = "ошибка ID сообщения";
|
||||
|
||||
/* call status */
|
||||
"call error" = "ошибка звонка";
|
||||
|
||||
/* call status */
|
||||
"call in progress" = "активный звонок";
|
||||
|
||||
/* call status */
|
||||
"calling…" = "входящий звонок…";
|
||||
|
||||
/* call status
|
||||
chat list item title */
|
||||
/* call status */
|
||||
"connecting call…" = "звонок соединяется…";
|
||||
|
||||
/* chat list item title */
|
||||
"connecting…" = "соединяется…";
|
||||
|
||||
/* chat list item title (it should not be shown */
|
||||
@@ -35,14 +52,11 @@
|
||||
/* deleted chat item */
|
||||
"deleted" = "удалено";
|
||||
|
||||
/* call status */
|
||||
"ended %@" = "завершен %@";
|
||||
/* integrity error chat item */
|
||||
"duplicate message" = "повторное сообщение";
|
||||
|
||||
/* call status */
|
||||
"error" = "ошибка соединения";
|
||||
|
||||
/* call status */
|
||||
"in progress" = "активный звонок";
|
||||
"ended call %@" = "завершённый звонок %@";
|
||||
|
||||
/* notification */
|
||||
"Incoming audio call" = "Входящий аудиозвонок";
|
||||
@@ -54,13 +68,10 @@
|
||||
"invited to connect" = "приглашение соединиться";
|
||||
|
||||
/* call status */
|
||||
"missed" = "пропущенный звонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"no e2e encryption" = "нет e2e шифрования";
|
||||
"missed call" = "пропущенный звонок";
|
||||
|
||||
/* call status */
|
||||
"rejected" = "отклоненный звонок";
|
||||
"rejected call" = "отклонённый звонок";
|
||||
|
||||
/* chat list item description */
|
||||
"via contact address link" = "через ссылку-контакт";
|
||||
@@ -69,10 +80,7 @@
|
||||
"via one-time link" = "через одноразовую ссылку";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"with e2e encryption" = "e2e зашифровано";
|
||||
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
|
||||
|
||||
/* notification body */
|
||||
"You can now send messages to %@" = "Вы можете отправлять сообщения %@";
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
|
||||
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
|
||||
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
|
||||
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
|
||||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
|
||||
@@ -26,7 +28,14 @@
|
||||
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; };
|
||||
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; };
|
||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; };
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; };
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; };
|
||||
5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */; };
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; };
|
||||
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; };
|
||||
5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; };
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
|
||||
5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; };
|
||||
5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; };
|
||||
@@ -95,6 +104,8 @@
|
||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
|
||||
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
|
||||
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
@@ -130,6 +141,8 @@
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
|
||||
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
|
||||
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
|
||||
5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; };
|
||||
@@ -144,8 +157,15 @@
|
||||
5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = "<group>"; };
|
||||
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = "<group>"; };
|
||||
5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = "<group>"; };
|
||||
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = "<group>"; };
|
||||
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.swift; sourceTree = "<group>"; };
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
|
||||
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
|
||||
5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = "<group>"; };
|
||||
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
|
||||
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; };
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; };
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
|
||||
5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = "<group>"; };
|
||||
5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = "<group>"; };
|
||||
@@ -211,6 +231,8 @@
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
|
||||
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
@@ -231,6 +253,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */,
|
||||
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */,
|
||||
64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */,
|
||||
64A6908728376BBA0076573F /* libgmp.a in Frameworks */,
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
|
||||
@@ -271,6 +294,10 @@
|
||||
3C714776281C081000CB4D4B /* WebRTCView.swift */,
|
||||
5C9D13A2282187BB00AB8B43 /* WebRTC.swift */,
|
||||
5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */,
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */,
|
||||
5C55A91E283AD0E400C4E99E /* CallManager.swift */,
|
||||
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */,
|
||||
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */,
|
||||
);
|
||||
path = Call;
|
||||
sourceTree = "<group>";
|
||||
@@ -319,6 +346,7 @@
|
||||
5C764E7A279C71D4000C6508 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */,
|
||||
5CDCAD6028187D7900503DA2 /* libz.tbd */,
|
||||
5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */,
|
||||
5C764E7C279C71DB000C6508 /* libz.tbd */,
|
||||
@@ -348,13 +376,8 @@
|
||||
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */,
|
||||
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */,
|
||||
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */,
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */,
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */,
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -362,6 +385,7 @@
|
||||
5CA059BD279559F40002BEB4 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */,
|
||||
3C714779281C0F6800CB4D4B /* www */,
|
||||
5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */,
|
||||
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
|
||||
@@ -441,10 +465,13 @@
|
||||
children = (
|
||||
5CB924D327A853F100ACCCDD /* SettingsButton.swift */,
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
|
||||
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
|
||||
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */,
|
||||
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
@@ -497,8 +524,12 @@
|
||||
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
|
||||
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */,
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */,
|
||||
5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */,
|
||||
);
|
||||
path = ChatItem;
|
||||
sourceTree = "<group>";
|
||||
@@ -509,6 +540,9 @@
|
||||
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
|
||||
5CEACCE227DE9246000BD591 /* ComposeView.swift */,
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */,
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */,
|
||||
);
|
||||
path = ComposeMessage;
|
||||
sourceTree = "<group>";
|
||||
@@ -602,7 +636,6 @@
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
ru,
|
||||
);
|
||||
mainGroup = 5CA059BD279559F40002BEB4;
|
||||
@@ -625,6 +658,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C55A92E283D0FDE00C4E99E /* sounds in Resources */,
|
||||
3C71477A281C0F6800CB4D4B /* www in Resources */,
|
||||
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */,
|
||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
|
||||
@@ -656,7 +690,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
5CDCAD7F281894FB00503DA2 /* API.swift in Sources */,
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||
5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */,
|
||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
||||
@@ -667,6 +703,8 @@
|
||||
5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */,
|
||||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
|
||||
@@ -692,19 +730,23 @@
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */,
|
||||
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */,
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */,
|
||||
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */,
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
|
||||
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
|
||||
5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */,
|
||||
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
|
||||
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */,
|
||||
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */,
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */,
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */,
|
||||
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
|
||||
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
|
||||
@@ -723,6 +765,7 @@
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
|
||||
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */,
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||
);
|
||||
@@ -923,13 +966,14 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -945,7 +989,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -965,13 +1009,14 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -987,7 +1032,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1046,7 +1091,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1067,7 +1112,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1086,7 +1131,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1107,7 +1152,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
showNonLocalizedStrings = "YES">
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Choose file" = "Choose file (new in v2.0)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Connecting to server…";
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
" (can be copied)" = " (можно скопировать)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
" wants to connect with you via " = " хочет связаться с вами через ";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_курсив_";
|
||||
|
||||
@@ -38,10 +35,10 @@
|
||||
"**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок.";
|
||||
"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок.";
|
||||
"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**.";
|
||||
@@ -64,6 +61,9 @@
|
||||
/* notification title */
|
||||
"%@ wants to connect!" = "%@ хочет соединиться!";
|
||||
|
||||
/* integrity error chat item */
|
||||
"%d skipped message(s)" = "%d пропущенных сообщение(й)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"%lld" = "%lld";
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
/* No comment provided by engineer. */
|
||||
"above, then choose:" = "наверху, затем выберите:";
|
||||
|
||||
/* accept contact request via notification */
|
||||
/* accept contact request via notification
|
||||
accept incoming call via notification */
|
||||
"Accept" = "Принять";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
@@ -98,7 +99,7 @@
|
||||
"Accept contact request from %@?" = "Принять запрос на соединение от %@?";
|
||||
|
||||
/* call status */
|
||||
"accepted" = "принятый звонок";
|
||||
"accepted call" = " принятый звонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Add contact to start a new chat" = "Добавьте контакт, чтобы начать разговор";
|
||||
@@ -112,9 +113,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Already connected?" = "Соединение уже установлено?";
|
||||
|
||||
/* accept incoming call via notification */
|
||||
"Answer" = "Ответить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Answer call" = "Принять звонок";
|
||||
|
||||
@@ -122,7 +120,25 @@
|
||||
"Attach" = "Прикрепить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный).";
|
||||
"Audio & video calls" = "Аудио- и видеозвонки";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Authentication failed" = "Ошибка аутентификации";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Authentication unavailable" = "Аутентификация недоступна";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Auto-accept images" = "Автоприем изображений";
|
||||
|
||||
/* integrity error chat item */
|
||||
"bad message hash" = "ошибка хэш сообщения";
|
||||
|
||||
/* integrity error chat item */
|
||||
"bad message ID" = "ошибка ID сообщения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"bold" = "жирный";
|
||||
@@ -130,15 +146,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Call already ended!" = "Звонок уже завершен!";
|
||||
|
||||
/* call status */
|
||||
"call error" = "ошибка звонка";
|
||||
|
||||
/* call status */
|
||||
"call in progress" = "активный звонок";
|
||||
|
||||
/* call status */
|
||||
"calling…" = "входящий звонок…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Cancel" = "Отменить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Capabilities" = "Возможности";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Chat console" = "Консоль";
|
||||
|
||||
@@ -146,7 +165,7 @@
|
||||
"Chat with the developers" = "Соединиться с разработчиками";
|
||||
|
||||
/* back button to return to chats list */
|
||||
"Chats" = "Назад";
|
||||
"Chats" = "Чаты";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Check messages" = "Проверять сообщения";
|
||||
@@ -155,7 +174,7 @@
|
||||
"Checking new messages..." = "Проверяются новые сообщения...";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Choose file" = "Выбрать файл (v2.0)";
|
||||
"Choose file" = "Выбрать файл";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Choose from library" = "Выбрать из библиотеки";
|
||||
@@ -193,20 +212,25 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Connect via one-time link?" = "Соединиться через одноразовую ссылку?";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connect via relay" = "Соединяться через сервер (relay)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connect with the developers" = "Соединиться с разработчиками";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"connected" = "соединение установлено";
|
||||
|
||||
/* call status */
|
||||
"connecting call…" = "звонок соединяется…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Устанавливается соединение с сервером…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server… (error: %@)" = "Устанавливается соединение с сервером… (ошибка: %@)";
|
||||
|
||||
/* call status
|
||||
chat list item title */
|
||||
/* chat list item title */
|
||||
"connecting…" = "соединяется…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
@@ -317,9 +341,27 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Develop" = "Для разработчиков";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Device" = "Устройство";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Device authentication is disabled. Turning off SimpleX Lock." = "Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.";
|
||||
|
||||
/* authentication reason */
|
||||
"Disable SimpleX Lock" = "Отключить блокировку SimpleX";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Display name" = "Имя профиля";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Do NOT use SimpleX for emergency calls." = "Не используйте SimpleX для экстренных звонков";
|
||||
|
||||
/* integrity error chat item */
|
||||
"duplicate message" = "повторное сообщение";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"e2e encrypted" = "e2e зашифровано";
|
||||
|
||||
@@ -329,18 +371,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)";
|
||||
|
||||
/* authentication reason */
|
||||
"Enable SimpleX Lock" = "Включить блокировку SimpleX";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"End" = "Завершить";
|
||||
"ended" = "завершён";
|
||||
|
||||
/* call status */
|
||||
"ended %@" = "завершен %@";
|
||||
"ended call %@" = "завершённый звонок %@";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:";
|
||||
|
||||
/* call status */
|
||||
"error" = "ошибка соединения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Error deleting token" = "Ошибка удаления токена";
|
||||
|
||||
@@ -384,7 +426,7 @@
|
||||
"How to use markdown" = "Как форматировать";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"ICE" = "ICE";
|
||||
"If the video fails to connect, flip the camera to resolve it." = "Если видео не соединилось, переключите камеру.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"If you can't meet in person, **show QR code in the video call**, or share the link." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой.";
|
||||
@@ -392,7 +434,7 @@
|
||||
/* No comment provided by engineer. */
|
||||
"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку.";
|
||||
|
||||
/* ignore incoming call via notification */
|
||||
/* No comment provided by engineer. */
|
||||
"Ignore" = "Не отвечать";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
@@ -404,9 +446,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"In person or via a video call – the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение";
|
||||
|
||||
/* call status */
|
||||
"in progress" = "активный звонок";
|
||||
|
||||
/* notification */
|
||||
"Incoming audio call" = "Входящий аудиозвонок";
|
||||
|
||||
@@ -425,6 +464,9 @@
|
||||
/* chat list item title */
|
||||
"invited to connect" = "приглашение";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages." = "Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@).";
|
||||
|
||||
@@ -437,6 +479,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Large file!" = "Большой файл!";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Limitations" = "Ограничения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Make a private connection" = "Добавьте контакт";
|
||||
|
||||
@@ -453,7 +498,7 @@
|
||||
"Message delivery error" = "Ошибка доставки сообщения";
|
||||
|
||||
/* call status */
|
||||
"missed" = "пропущенный звонок";
|
||||
"missed call" = "пропущенный звонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами.";
|
||||
@@ -512,6 +557,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." = "Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Privacy & security" = "Конфиденциальность";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Privacy redefined" = "Более конфиденциальный";
|
||||
|
||||
@@ -531,6 +582,9 @@
|
||||
"received answer…" = "получен ответ…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"received confirmation…" = "получено подтверждение…";
|
||||
|
||||
/* reject incoming call via notification */
|
||||
"Reject" = "Отклонить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
@@ -540,11 +594,14 @@
|
||||
"Reject contact request" = "Отклонить запрос";
|
||||
|
||||
/* call status */
|
||||
"rejected" = "отклоненный звонок";
|
||||
"rejected call" = "отклонённый звонок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Reply" = "Ответить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Retry" = "Повторить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save" = "Сохранить";
|
||||
|
||||
@@ -564,7 +621,7 @@
|
||||
"secret" = "секрет";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Send" = "Отправить";
|
||||
"Send link previews" = "Отправлять картинки ссылок";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Server connected" = "Установлено соединение с сервером";
|
||||
@@ -585,10 +642,16 @@
|
||||
"Show pending connections" = "Показать ожидаемые соединения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"SMP servers" = "SMP серверы";
|
||||
"SimpleX Lock" = "Блокировка SimpleX";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Start" = "Начать";
|
||||
"SimpleX Lock turned on" = "Блокировка SimpleX включена";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Skipped messages" = "Пропущенные сообщения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"SMP servers" = "SMP серверы";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"starting…" = "инициализация…";
|
||||
@@ -620,6 +683,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"The microphone does not work when the app is in the background." = "Микрофон не работает, когда приложение в фоновом режиме.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"The next generation of private messaging" = "Новое поколение приватных сообщений";
|
||||
|
||||
@@ -641,15 +707,24 @@
|
||||
/* No comment provided by engineer. */
|
||||
"To make your first private connection, choose **one of the following**:" = "Чтобы добавить ваш первый контакт, выберите **одно из**:";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"To prevent the call interruption, enable Do Not Disturb mode." = "Чтобы избежать прерывания звонков, включите режим Не Беспокоить.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Turn on" = "Включить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Unexpected error: %@" = "Неожиданная ошибка: %@";
|
||||
|
||||
@@ -659,6 +734,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.";
|
||||
|
||||
/* authentication reason */
|
||||
"Unlock" = "Разблокировать";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?";
|
||||
|
||||
@@ -678,7 +756,7 @@
|
||||
"via relay" = "через relay сервер";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный).";
|
||||
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"waiting for answer…" = "ожидается ответ…";
|
||||
@@ -698,9 +776,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Welcome %@!" = "Здравствуйте %@!";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"with e2e encryption" = "e2e зашифровано";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You" = "Вы";
|
||||
|
||||
@@ -728,6 +803,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You could not be verified; please try again." = "Верификация не удалась; пожалуйста, попробуйте ещё раз.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You invited your contact" = "Вы пригласили ваш контакт";
|
||||
|
||||
@@ -740,6 +818,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"You will be connected when your contact's device is online, please wait or check later!" = "Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your calls" = "Ваши звонки";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your chat address" = "Ваш SimpleX адрес";
|
||||
|
||||
@@ -761,6 +845,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт отправил файл, размер которого превышает максимальный размер (%@).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your privacy" = "Конфиденциальность";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю.";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user