Merge pull request #718 from simplex-chat/master (version 2.2.0)

This commit is contained in:
JRoberts
2022-06-01 19:11:01 +04:00
committed by GitHub
114 changed files with 4624 additions and 1584 deletions
+5
View File
@@ -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" />
+6 -2
View File
@@ -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"
+51 -34
View File
@@ -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 }) {
+18 -3
View File
@@ -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) },
@@ -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
@@ -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()
)
}
}
@@ -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 {
@@ -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(
@@ -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
})
}
}
@@ -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)
}
}
}
@@ -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 &amp; 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 &amp; 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>
+1 -1
View File
@@ -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
+110 -16
View File
@@ -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))
}
}
+3 -1
View File
@@ -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> = [:]
+9 -20
View File
@@ -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
+2 -2
View File
@@ -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))"
+11 -11
View File
@@ -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 {
+36 -7
View File
@@ -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]
)
}
+39 -13
View File
@@ -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)")
+15 -2
View File
@@ -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
}
}
}
+219 -117
View File
@@ -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
}
}
+49 -69
View File
@@ -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"
}
}
}
+113 -102
View File
@@ -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!")
}
@@ -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 -19
View File
@@ -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)
}
@@ -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 &amp; video calls" xml:space="preserve">
<source>Audio &amp; video calls</source>
<target>Audio &amp; 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:&#10;1. The messages expire on the server if they were not received for 30 days,&#10;2. The server you use to receive the messages from this contact was updated and restarted.&#10;3. The connection is compromised.&#10;Please connect to the developers via Settings to receive the updates about the servers.&#10;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 &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source>
<target>Privacy &amp; 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.&#10;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.&#10;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">
@@ -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,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 &amp; video calls" xml:space="preserve">
<source>Audio &amp; 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:&#10;1. The messages expire on the server if they were not received for 30 days,&#10;2. The server you use to receive the messages from this contact was updated and restarted.&#10;3. The connection is compromised.&#10;Please connect to the developers via Settings to receive the updates about the servers.&#10;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 &amp; security" xml:space="preserve">
<source>Privacy &amp; 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.&#10;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.&#10;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">
@@ -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,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 %@" = "Вы можете отправлять сообщения %@";
+60 -15
View File
@@ -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
-3
View File
@@ -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…";
+124 -37
View File
@@ -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