diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 7cbf6ab6bd..d75a79b49f 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
+
+
+
SplashView()
- onboarding == OnboardingStage.OnboardingComplete && userCreated ->
- if (chatModel.showCallView.value) ActiveCallView(chatModel)
- else if (chatModel.chatId.value == null) ChatListView(chatModel)
- else ChatView(chatModel)
- onboarding == OnboardingStage.Step1_SimpleXInfo ->
+ onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
+ Box {
+ if (chatModel.showCallView.value) ActiveCallView(chatModel)
+ else if (chatModel.chatId.value == null) ChatListView(chatModel)
+ else ChatView(chatModel)
+
+ val invitation = chatModel.activeCallInvitation.value
+ if (invitation != null) IncomingCallAlertView(invitation, chatModel)
+ }
+ } onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
SimpleXInfo(chatModel, onboarding = true)
}
@@ -122,6 +129,16 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
+ NtfManager.AcceptCallAction -> {
+ val chatId = intent.getStringExtra("chatId")
+ Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
+ val invitation = chatModel.callInvitations[chatId]
+ if (invitation == null) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
+ } else {
+ chatModel.callManager.acceptIncomingCall(invitation = invitation)
+ }
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
index 9559f9fd70..bdb8fb5d09 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -5,8 +5,7 @@ import android.net.LocalServerSocket
import android.util.Log
import androidx.lifecycle.*
import chat.simplex.app.model.*
-import chat.simplex.app.views.helpers.getFilesDirectory
-import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import java.io.BufferedReader
import java.io.InputStreamReader
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index 7f7c8ec89c..824701dae4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -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
@@ -41,11 +40,13 @@ class ChatModel(val controller: ChatController) {
val runServiceInBackground = mutableStateOf(true)
// current WebRTC call
+ val callManager = CallManager(this)
val callInvitations = mutableStateMapOf()
- val activeCallInvitation = mutableStateOf(null)
+ val activeCallInvitation = mutableStateOf(null)
val activeCall = mutableStateOf(null)
val callCommand = mutableStateOf(null)
val showCallView = mutableStateOf(false)
+ val switchingCall = mutableStateOf(false)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
@@ -457,10 +458,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 +688,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
@@ -1080,7 +1074,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
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
index 1b7aa8438d..0ba0004d71 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
@@ -1,14 +1,17 @@
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) {
@@ -17,6 +20,12 @@ class NtfManager(val context: Context) {
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) {
@@ -58,7 +78,7 @@ class NtfManager(val context: Context) {
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
- .setContentIntent(getMsgPendingIntent(cInfo.id))
+ .setContentIntent(chatPendingIntent(OpenChatAction, cInfo.id))
.setSilent(recentNotification)
.build()
@@ -68,7 +88,7 @@ class NtfManager(val context: Context) {
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setGroupSummary(true)
- .setContentIntent(getSummaryNtfIntent())
+ .setContentIntent(chatPendingIntent(ShowChatsAction))
.build()
with(NotificationManagerCompat.from(context)) {
@@ -78,37 +98,51 @@ class NtfManager(val context: Context) {
}
}
- 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) {
+ val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
+ val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT)
+ NotificationCompat.Builder(context, LockScreenCallChannel)
+ .setFullScreenIntent(fullScreenPendingIntent, true)
+ .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 +160,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)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index 21f5722e0c..6c525e531a 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -23,8 +23,7 @@ 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,6 +33,18 @@ import kotlin.concurrent.thread
typealias ChatCtrl = Long
+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
+}
+
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)
@@ -69,22 +80,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
@@ -454,7 +453,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
}
- if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
+ if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -501,36 +500,8 @@ 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)
+ chatModel.callManager.reportNewIncomingCall(invitation)
}
is CR.CallOffer -> {
// TODO askConfirmation?
@@ -542,7 +513,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
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 +523,17 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
is CR.CallEnded -> {
- withCall(r, r.contact) { _ ->
+ val invitation = chatModel.callInvitations.remove(r.contact.id)
+ if (invitation != null) {
+ chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
+ }
+ withCall(r, r.contact) { call ->
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}")
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index 262279053e..14e7f20054 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -8,7 +8,7 @@ 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)
@@ -17,6 +17,8 @@ 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 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)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt
new file mode 100644
index 0000000000..13bad493b1
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt
@@ -0,0 +1,90 @@
+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.withApi
+
+class CallManager(val chatModel: ChatModel) {
+ fun reportNewIncomingCall(invitation: CallInvitation) {
+ Log.d(TAG, "CallManager.reportNewIncomingCall")
+ with (chatModel) {
+ callInvitations[invitation.contact.id] = invitation
+ activeCallInvitation.value = invitation
+ controller.ntfManager.notifyCallInvitation(invitation)
+ }
+ }
+
+ fun acceptIncomingCall(invitation: CallInvitation) {
+ 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
+ callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey)
+ 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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
index 57a816eb3c..391ca33061 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
@@ -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,7 +31,6 @@ 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 com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
@@ -94,15 +83,25 @@ 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)
+ endCall()
+ }
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.Camera -> {
+ chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
+ if (!call.audioEnabled) {
+ chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
+ }
+ }
is WCallCommand.End -> endCall()
else -> {}
}
@@ -153,10 +152,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(40.dp))
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
}
}
@@ -208,7 +208,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 +273,6 @@ private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
//}
@Composable
-// for debugging
-// fun WebRTCView(callCommand: MutableState, onResponse: (String) -> Unit) {
fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) {
val webView = remember { mutableStateOf(null) }
val permissionsState = rememberMultiplePermissionsState(
@@ -359,8 +357,6 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa
}
}
}
- } else {
- Text("NEED PERMISSIONS")
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
new file mode 100644
index 0000000000..0226a4dacd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
@@ -0,0 +1,201 @@
+package chat.simplex.app.views.call
+
+import android.app.KeyguardManager
+import android.content.*
+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.clickable
+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.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.*
+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.helpers.ProfileImage
+import chat.simplex.app.views.onboarding.SimpleXLogo
+
+class IncomingCallActivity: ComponentActivity() {
+ private val vm by viewModels()
+
+ 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(darkTheme = true) {
+ 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)
+ }
+ }
+ }
+}
+
+@Composable
+fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel) {
+ val cm = chatModel.callManager
+ val cxt = LocalContext.current
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
+ DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
+ IncomingCallLockScreenAlertLayout(
+ invitation,
+ rejectCall = { cm.endCall(invitation = invitation) },
+ ignoreCall = { chatModel.activeCallInvitation.value = null },
+ acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
+ )
+}
+
+@Composable
+fun IncomingCallLockScreenAlertLayout(
+ invitation: CallInvitation,
+ rejectCall: () -> Unit,
+ ignoreCall: () -> Unit,
+ acceptCall: () -> Unit
+) {
+ Column(
+ Modifier
+ .padding(30.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ IncomingCallInfo(invitation)
+ Spacer(
+ Modifier
+ .fillMaxHeight()
+ .weight(1f))
+ 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)
+ }
+ }
+}
+
+@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
+ ),
+ rejectCall = {},
+ ignoreCall = {},
+ acceptCall = {}
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
new file mode 100644
index 0000000000..269381281e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
@@ -0,0 +1,107 @@
+package chat.simplex.app.views.call
+
+import androidx.annotation.StringRes
+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
+
+@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
+ ),
+ rejectCall = {},
+ ignoreCall = {},
+ acceptCall = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt
new file mode 100644
index 0000000000..f451330a7a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
index a415710319..f2a8173bf7 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
@@ -26,7 +26,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 +36,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 +89,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 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 +100,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)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index ef1c52209b..2a9825bd1c 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -126,15 +126,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)
}
}
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
similarity index 96%
rename from apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
index a2c0e166c0..65e85e0b75 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
@@ -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)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
index b19f17a2be..58310a2856 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
@@ -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 {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
index 478bc7b5fb..2235a82cbc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
@@ -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
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt
index 3825954bbc..8c3f467aac 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt
@@ -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) {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index 9ab041d283..6431988fa4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -19,8 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
@@ -84,16 +84,7 @@ fun SettingsLayout(
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)
- }
+ ProfilePreview(profile)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { UserAddressView(it) }) {
@@ -215,6 +206,19 @@ fun SettingsLayout(
}
}
+@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)) {
val modifier = Modifier
diff --git a/apps/android/app/src/main/res/drawable-hdpi/icon.png b/apps/android/app/src/main/res/drawable-hdpi/icon.png
new file mode 100644
index 0000000000..9c6efe0589
Binary files /dev/null and b/apps/android/app/src/main/res/drawable-hdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/raw/ring_once.mp3 b/apps/android/app/src/main/res/raw/ring_once.mp3
new file mode 100644
index 0000000000..9583277336
Binary files /dev/null and b/apps/android/app/src/main/res/raw/ring_once.mp3 differ
diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml
index 624094c39d..5b665e6a1b 100644
--- a/apps/android/app/src/main/res/values-ru/strings.xml
+++ b/apps/android/app/src/main/res/values-ru/strings.xml
@@ -294,18 +294,20 @@
пропущенный звонок
отклоненный звонок
принятый звонок
- соединяется…
+ звонок соединяется…
активный звонок
звонок завершён %1$s
- ошибка соединения
+ ошибка звонка
инициализация…
ожидается ответ…
ожидается подтверждение…
получен ответ…
+ получено подтверждение…
соединяется…
соединено
+ завершен
Новое поколение приватных сообщений
@@ -348,7 +350,9 @@
e2e зашифрованный видеозвонок
аудиозвонок (не e2e зашифрованный)
e2e зашифрованный аудиозвонок
- Принять
+ Принять
+ Отклонить
+ Закрыть
Звонок уже завершен!
видеозвонок
аудиозвонок
diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml
index 3996718c6a..0c96aff28f 100644
--- a/apps/android/app/src/main/res/values/strings.xml
+++ b/apps/android/app/src/main/res/values/strings.xml
@@ -290,23 +290,25 @@
colored
secret
-
+
calling…
- missed
- rejected
- accepted
- connecting…
- in progress
- ended %1$s
- error
+ missed call
+ rejected call
+ accepted call
+ connecting call…
+ call in progress
+ call ended %1$s
+ call error
starting…
waiting for answer…
waiting for confirmation…
received answer…
+ received confirmation…
connecting…
connected
+ ended
The next generation of private messaging
@@ -349,7 +351,9 @@
e2e encrypted video call
audio call (not e2e encrypted)
e2e encrypted audio call
- Answer
+ Accept
+ Reject
+ Ignore
Call already ended!
video call
audio call
diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift
index f22535e9ad..19d049e80d 100644
--- a/apps/ios/Shared/Views/Call/CallManager.swift
+++ b/apps/ios/Shared/Views/Call/CallManager.swift
@@ -61,12 +61,12 @@ class CallManager {
func endCall(call: Call, completed: @escaping () -> Void) {
let m = ChatModel.shared
if case .ended = call.callState {
- logger.debug("CallController.provider CXEndCallAction: call ended")
+ logger.debug("CallManager.endCall: call ended")
m.activeCall = nil
m.showCallView = false
completed()
} else {
- logger.debug("CallController.provider CXEndCallAction: ending call...")
+ logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end
m.showCallView = false
Task {
diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift
index 428ef130b5..93a61c3f4d 100644
--- a/apps/ios/Shared/Views/Call/WebRTCView.swift
+++ b/apps/ios/Shared/Views/Call/WebRTCView.swift
@@ -111,78 +111,72 @@ func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) {
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
-
- 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") {
-
- }
- }
- }
- }
-}
+//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") {
+//
+// }
+// }
+// }
// }
//}