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") { +// +// } +// } +// } // } //}