diff --git a/app/build.gradle b/app/build.gradle index bdfac64e7..18370f9cf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,6 +17,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'witness' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-parcelize' apply plugin: 'com.google.gms.google-services' apply plugin: 'kotlinx-serialization' apply plugin: 'dagger.hilt.android.plugin' @@ -54,7 +55,7 @@ dependencies { implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1' implementation 'org.conscrypt:conscrypt-android:2.0.0' implementation 'org.signal:aesgcmprovider:0.0.3' - implementation 'org.whispersystems:webrtc-android:M74' + implementation 'org.webrtc:google-webrtc:1.0.32006' implementation "me.leolin:ShortcutBadger:1.1.16" implementation 'se.emilsjolander:stickylistheaders:2.7.0' implementation 'com.jpardogo.materialtabstrip:library:1.0.9' @@ -157,8 +158,8 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.4' } -def canonicalVersionCode = 261 -def canonicalVersionName = "1.11.20" +def canonicalVersionCode = 272 +def canonicalVersionName = "1.12.15" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54ec5ea7a..9bbbdbc36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,7 @@ android:name="android.hardware.touchscreen" android:required="false" /> + @@ -51,9 +52,9 @@ - - + + @@ -300,6 +301,16 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="true" android:theme="@style/Theme.Session.DayNight.NoActionBar" /> + + + + () + private val glide by lazy { GlideApp.with(this) } + private lateinit var binding: ActivityWebrtcBinding + private var uiJob: Job? = null + private var wantsToAnswer = false + set(value) { + field = value + WebRtcCallService.broadcastWantsToAnswer(this, value) + } + private var hangupReceiver: BroadcastReceiver? = null + + private val rotationListener by lazy { + object : OrientationEventListener(this) { + override fun onOrientationChanged(orientation: Int) { + if ((orientation + 15) % 90 < 30) { + viewModel.deviceRotation = orientation +// updateControlsRotation(orientation.quadrantRotation() * -1) + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + if (intent?.action == ACTION_ANSWER) { + val answerIntent = WebRtcCallService.acceptCallIntent(this) + ContextCompat.startForegroundService(this, answerIntent) + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + rotationListener.enable() + binding = ActivityWebrtcBinding.inflate(layoutInflater) + setContentView(binding.root) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + ) + volumeControlStream = AudioManager.STREAM_VOICE_CALL + + if (intent.action == ACTION_ANSWER) { + answerCall() + } + if (intent.action == ACTION_PRE_OFFER) { + wantsToAnswer = true + answerCall() // this will do nothing, except update notification state + } + if (intent.action == ACTION_FULL_SCREEN_INTENT) { + supportActionBar?.setDisplayHomeAsUpEnabled(false) + } + + binding.microphoneButton.setOnClickListener { + val audioEnabledIntent = + WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled) + startService(audioEnabledIntent) + } + + binding.speakerPhoneButton.setOnClickListener { + val command = + AudioManagerCommand.SetUserDevice(if (viewModel.isSpeaker) EARPIECE else SPEAKER_PHONE) + WebRtcCallService.sendAudioManagerCommand(this, command) + } + + binding.acceptCallButton.setOnClickListener { + if (viewModel.currentCallState == CALL_PRE_INIT) { + wantsToAnswer = true + updateControls() + } + answerCall() + } + + binding.declineCallButton.setOnClickListener { + val declineIntent = WebRtcCallService.denyCallIntent(this) + startService(declineIntent) + } + + hangupReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + finish() + } + } + + LocalBroadcastManager.getInstance(this) + .registerReceiver(hangupReceiver!!, IntentFilter(ACTION_END)) + + binding.enableCameraButton.setOnClickListener { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .onAllGranted { + val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoEnabled) + startService(intent) + } + .execute() + } + + binding.switchCameraButton.setOnClickListener { + startService(WebRtcCallService.flipCamera(this)) + } + + binding.endCallButton.setOnClickListener { + startService(WebRtcCallService.hangupIntent(this)) + } + binding.backArrow.setOnClickListener { + onBackPressed() + } + + } + + override fun onDestroy() { + super.onDestroy() + hangupReceiver?.let { receiver -> + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) + } + rotationListener.disable() + } + + private fun answerCall() { + val answerIntent = WebRtcCallService.acceptCallIntent(this) + ContextCompat.startForegroundService(this, answerIntent) + } + + private fun updateControlsRotation(newRotation: Int) { + with (binding) { + val rotation = newRotation.toFloat() + remoteRecipient.rotation = rotation + speakerPhoneButton.rotation = rotation + microphoneButton.rotation = rotation + enableCameraButton.rotation = rotation + switchCameraButton.rotation = rotation + endCallButton.rotation = rotation + } + } + + private fun updateControls(state: CallViewModel.State? = null) { + with(binding) { + if (state == null) { + if (wantsToAnswer) { + controlGroup.isVisible = true + remoteLoadingView.isVisible = true + incomingControlGroup.isVisible = false + } + } else { + controlGroup.isVisible = state in listOf( + CALL_CONNECTED, + CALL_OUTGOING, + CALL_INCOMING + ) || (state == CALL_PRE_INIT && wantsToAnswer) + remoteLoadingView.isVisible = + state !in listOf(CALL_CONNECTED, CALL_RINGING, CALL_PRE_INIT) || wantsToAnswer + incomingControlGroup.isVisible = + state in listOf(CALL_RINGING, CALL_PRE_INIT) && !wantsToAnswer + reconnectingText.isVisible = state == CALL_RECONNECTING + endCallButton.isVisible = endCallButton.isVisible || state == CALL_RECONNECTING + } + } + } + + override fun onStart() { + super.onStart() + + uiJob = lifecycleScope.launch { + + launch { + viewModel.audioDeviceState.collect { state -> + val speakerEnabled = state.selectedDevice == SPEAKER_PHONE + // change drawable background to enabled or not + binding.speakerPhoneButton.isSelected = speakerEnabled + } + } + + launch { + viewModel.callState.collect { state -> + Log.d("Loki", "Consuming view model state $state") + when (state) { + CALL_RINGING -> { + if (wantsToAnswer) { + answerCall() + wantsToAnswer = false + } + } + CALL_OUTGOING -> { + } + CALL_CONNECTED -> { + wantsToAnswer = false + } + } + updateControls(state) + } + } + + launch { + viewModel.recipient.collect { latestRecipient -> + if (latestRecipient.recipient != null) { + val publicKey = latestRecipient.recipient.address.serialize() + val displayName = getUserDisplayName(publicKey) + supportActionBar?.title = displayName + val signalProfilePicture = latestRecipient.recipient.contactPhoto + val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject + val sizeInPX = + resources.getDimensionPixelSize(R.dimen.extra_large_profile_picture_size) + binding.remoteRecipientName.text = displayName + if (signalProfilePicture != null && avatar != "0" && avatar != "") { + glide.clear(binding.remoteRecipient) + glide.load(signalProfilePicture) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .circleCrop() + .error( + AvatarPlaceholderGenerator.generate( + this@WebRtcCallActivity, + sizeInPX, + publicKey, + displayName + ) + ) + .into(binding.remoteRecipient) + } else { + glide.clear(binding.remoteRecipient) + glide.load( + AvatarPlaceholderGenerator.generate( + this@WebRtcCallActivity, + sizeInPX, + publicKey, + displayName + ) + ) + .diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop() + .into(binding.remoteRecipient) + } + } else { + glide.clear(binding.remoteRecipient) + } + } + } + + launch { + while (isActive) { + val startTime = viewModel.callStartTime + if (startTime == -1L) { + binding.callTime.isVisible = false + } else { + binding.callTime.isVisible = true + binding.callTime.text = DurationFormatUtils.formatDuration( + System.currentTimeMillis() - startTime, + CALL_DURATION_FORMAT + ) + } + + delay(1_000) + } + } + + launch { + viewModel.localAudioEnabledState.collect { isEnabled -> + // change drawable background to enabled or not + binding.microphoneButton.isSelected = !isEnabled + } + } + + launch { + viewModel.localVideoEnabledState.collect { isEnabled -> + binding.localRenderer.removeAllViews() + if (isEnabled) { + viewModel.localRenderer?.let { surfaceView -> + surfaceView.setZOrderOnTop(true) + binding.localRenderer.addView(surfaceView) + } + } + binding.localRenderer.isVisible = isEnabled + binding.enableCameraButton.isSelected = isEnabled + } + } + + launch { + viewModel.remoteVideoEnabledState.collect { isEnabled -> + binding.remoteRenderer.removeAllViews() + if (isEnabled) { + viewModel.remoteRenderer?.let { surfaceView -> + binding.remoteRenderer.addView(surfaceView) + } + } + binding.remoteRenderer.isVisible = isEnabled + binding.remoteRecipient.isVisible = !isEnabled + } + } + } + } + + private fun getUserDisplayName(publicKey: String): String { + val contact = + DatabaseComponent.get(this).sessionContactDatabase().getContactWithSessionID(publicKey) + return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey + } + + override fun onStop() { + super.onStop() + uiJob?.cancel() + binding.remoteRenderer.removeAllViews() + binding.localRenderer.removeAllViews() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index df8544a39..e5419774f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -71,6 +71,7 @@ class ProfilePictureView : RelativeLayout { } fun update() { + if (!this::glide.isInitialized) return val publicKey = publicKey ?: return val additionalPublicKey = additionalPublicKey if (additionalPublicKey != null) { @@ -104,12 +105,16 @@ class ProfilePictureView : RelativeLayout { if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return val signalProfilePicture = recipient.contactPhoto val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject + val sizeInPX = resources.getDimensionPixelSize(sizeResId) if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.clear(imageView) - glide.load(signalProfilePicture).diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).circleCrop().into(imageView) + glide.load(signalProfilePicture) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .circleCrop() + .error(AvatarPlaceholderGenerator.generate(context,sizeInPX, publicKey, displayName)) + .into(imageView) profilePicturesCache[publicKey] = recipient.profileAvatar } else { - val sizeInPX = resources.getDimensionPixelSize(sizeResId) glide.clear(imageView) glide.load(AvatarPlaceholderGenerator.generate(context, sizeInPX, publicKey, displayName)) .diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index e7c5b7e9e..67aae625b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -170,7 +170,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { - ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository(this))) + ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) .get(LinkPreviewViewModel::class.java) } private val viewModel: ConversationViewModel by viewModels { @@ -558,7 +558,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onPrepareOptionsMenu(menu: Menu): Boolean { if (!isMessageRequestThread()) { - ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, viewModel.recipient, viewModel.threadId, this) { onOptionsItemSelected(it) } + ConversationMenuHelper.onPrepareOptionsMenu( + menu, + menuInflater, + viewModel.recipient, + viewModel.threadId, + this + ) { onOptionsItemSelected(it) } } super.onPrepareOptionsMenu(menu) return true diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 0c8bda33f..c90739ab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.app.AlertDialog import android.content.Context +import android.content.Intent import android.database.Cursor import android.view.MotionEvent import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView.ViewHolder +import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView @@ -12,6 +15,7 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit, @@ -76,7 +80,26 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr } view.contentViewDelegate = visibleMessageContentViewDelegate } - is ControlMessageViewHolder -> viewHolder.view.bind(message, messageBefore) + is ControlMessageViewHolder -> { + viewHolder.view.bind(message, messageBefore) + if (message.isCallLog && message.isFirstMissedCall) { + viewHolder.view.setOnClickListener { + AlertDialog.Builder(context) + .setTitle(R.string.CallNotificationBuilder_first_call_title) + .setMessage(R.string.CallNotificationBuilder_first_call_message) + .setPositiveButton(R.string.activity_settings_title) { _, _ -> + val intent = Intent(context, PrivacySettingsActivity::class.java) + context.startActivity(intent) + } + .setNeutralButton(R.string.cancel) { d, _ -> + d.dismiss() + } + .show() + } + } else { + viewHolder.view.setOnClickListener(null) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 5966e765e..5669cafa4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -39,19 +39,29 @@ import org.thoughtcrime.securesms.MediaOverviewActivity import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.ShortcutLauncherActivity +import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey +import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity +import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.getColorWithID import java.io.IOException object ConversationMenuHelper { - fun onPrepareOptionsMenu(menu: Menu, inflater: MenuInflater, thread: Recipient, threadId: Long, context: Context, onOptionsItemSelected: (MenuItem) -> Unit) { + fun onPrepareOptionsMenu( + menu: Menu, + inflater: MenuInflater, + thread: Recipient, + threadId: Long, + context: Context, + onOptionsItemSelected: (MenuItem) -> Unit + ) { // Prepare menu.clear() val isOpenGroup = thread.isOpenGroupRecipient @@ -100,6 +110,10 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_notification_settings, menu) } + if (!thread.isGroupRecipient && thread.hasApprovedMe()) { + inflater.inflate(R.menu.menu_conversation_call, menu) + } + // Search val searchViewItem = menu.findItem(R.id.menu_search) (context as ConversationActivityV2).searchViewItem = searchViewItem @@ -150,6 +164,7 @@ object ConversationMenuHelper { R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) } R.id.menu_notification_settings -> { setNotifyType(context, thread) } + R.id.menu_call -> { call(context, thread) } } return true } @@ -166,6 +181,32 @@ object ConversationMenuHelper { searchViewModel.onSearchOpened() } + private fun call(context: Context, thread: Recipient) { + + if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { + AlertDialog.Builder(context) + .setTitle(R.string.ConversationActivity_call_title) + .setMessage(R.string.ConversationActivity_call_prompt) + .setPositiveButton(R.string.activity_settings_title) { _, _ -> + val intent = Intent(context, PrivacySettingsActivity::class.java) + context.startActivity(intent) + } + .setNeutralButton(R.string.cancel) { d, _ -> + d.dismiss() + }.show() + return + } + + val service = WebRtcCallService.createCall(context, thread) + context.startService(service) + + val activity = Intent(context, WebRtcCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(activity) + + } + @SuppressLint("StaticFieldLeak") private fun addShortcut(context: Context, thread: Recipient) { object : AsyncTask() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index e4247b86f..a4e4a52d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -29,6 +29,7 @@ class ControlMessageView : LinearLayout { // region Updating fun bind(message: MessageRecord, previous: MessageRecord?) { binding.dateBreakTextView.showDateBreak(message, previous) + binding.iconImageView.visibility = View.GONE var messageBody: CharSequence = message.getDisplayBody(context) when { message.isExpirationTimerUpdate -> { @@ -46,6 +47,16 @@ class ControlMessageView : LinearLayout { message.isMessageRequestResponse -> { messageBody = context.getString(R.string.message_requests_accepted) } + message.isCallLog -> { + val drawable = when { + message.isIncomingCall -> R.drawable.ic_incoming_call + message.isOutgoingCall -> R.drawable.ic_outgoing_call + message.isFirstMissedCall -> R.drawable.ic_info_outline_light + else -> R.drawable.ic_missed_call + } + binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme)) + binding.iconImageView.visibility = View.VISIBLE + } } binding.textView.text = messageBody diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 92f9e970d..5905433b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -32,6 +32,7 @@ public interface MmsSmsColumns { protected static final long OUTGOING_CALL_TYPE = 2; protected static final long MISSED_CALL_TYPE = 3; protected static final long JOINED_TYPE = 4; + protected static final long FIRST_MISSED_CALL_TYPE = 5; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; @@ -207,7 +208,8 @@ public interface MmsSmsColumns { } public static boolean isCallLog(long type) { - return type == INCOMING_CALL_TYPE || type == OUTGOING_CALL_TYPE || type == MISSED_CALL_TYPE; + long baseType = type & BASE_TYPE_MASK; + return baseType == INCOMING_CALL_TYPE || baseType == OUTGOING_CALL_TYPE || baseType == MISSED_CALL_TYPE || baseType == FIRST_MISSED_CALL_TYPE; } public static boolean isExpirationTimerUpdate(long type) { @@ -227,17 +229,22 @@ public interface MmsSmsColumns { } public static boolean isIncomingCall(long type) { - return type == INCOMING_CALL_TYPE; + return (type & BASE_TYPE_MASK) == INCOMING_CALL_TYPE; } public static boolean isOutgoingCall(long type) { - return type == OUTGOING_CALL_TYPE; + return (type & BASE_TYPE_MASK) == OUTGOING_CALL_TYPE; } public static boolean isMissedCall(long type) { - return type == MISSED_CALL_TYPE; + return (type & BASE_TYPE_MASK) == MISSED_CALL_TYPE; } + public static boolean isFirstMissedCall(long type) { + return (type & BASE_TYPE_MASK) == FIRST_MISSED_CALL_TYPE; + } + + public static boolean isGroupUpdate(long type) { return (type & GROUP_UPDATE_BIT) != 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 558957515..a34de25a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -28,6 +28,7 @@ import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteStatement; +import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; @@ -373,6 +374,24 @@ public class SmsDatabase extends MessagingDatabase { if (message.isOpenGroupInvitation()) type |= Types.OPEN_GROUP_INVITATION_BIT; + CallMessageType callMessageType = message.getCallType(); + if (callMessageType != null) { + switch (callMessageType) { + case CALL_OUTGOING: + type |= Types.OUTGOING_CALL_TYPE; + break; + case CALL_INCOMING: + type |= Types.INCOMING_CALL_TYPE; + break; + case CALL_MISSED: + type |= Types.MISSED_CALL_TYPE; + break; + case CALL_FIRST_MISSED: + type |= Types.FIRST_MISSED_CALL_TYPE; + break; + } + } + Recipient recipient = Recipient.from(context, message.getSender(), true); Recipient groupRecipient; @@ -384,7 +403,7 @@ public class SmsDatabase extends MessagingDatabase { } boolean unread = (Util.isDefaultSmsProvider(context) || - message.isSecureMessage() || message.isGroup()); + message.isSecureMessage() || message.isGroup() || message.isCallInfo()); long threadId; @@ -441,6 +460,10 @@ public class SmsDatabase extends MessagingDatabase { return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0); } + public Optional insertCallMessage(IncomingTextMessage message) { + return insertMessageInbox(message, 0, 0); + } + public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp) { return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 657e699b5..0a3d3c45d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob @@ -716,4 +717,20 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) } + + override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { + val database = DatabaseComponent.get(context).smsDatabase() + val address = fromSerialized(senderPublicKey) + val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp) + database.insertCallMessage(callMessage) + } + + override fun conversationHasOutgoing(userPublicKey: String): Boolean { + val database = DatabaseComponent.get(context).threadDatabase() + val threadId = database.getThreadIdIfExistsFor(userPublicKey) + + if (threadId == -1L) return false + + return database.getLastSeenAndHasSent(threadId).second() ?: false + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 9a47de785..9e105025f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -547,22 +547,10 @@ public class ThreadDatabase extends Database { SessionMetaProtocol.clearReceivedMessages(); } - public boolean hasThread(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, ID_WHERE, new String[]{ String.valueOf(threadId) }, null, null, null); - - try { - if (cursor != null && cursor.moveToFirst()) { return true; } - return false; - } finally { - if (cursor != null) cursor.close(); - } - } - - public long getThreadIdIfExistsFor(Recipient recipient) { + public long getThreadIdIfExistsFor(String address) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String where = ADDRESS + " = ?"; - String[] recipientsArg = new String[] {recipient.getAddress().serialize()}; + String[] recipientsArg = new String[] {address}; Cursor cursor = null; try { @@ -578,6 +566,10 @@ public class ThreadDatabase extends Database { } } + public long getThreadIdIfExistsFor(Recipient recipient) { + return getThreadIdIfExistsFor(recipient.getAddress().serialize()); + } + public long getOrCreateThreadIdFor(Recipient recipient) { return getOrCreateThreadIdFor(recipient, DistributionTypes.DEFAULT); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 141c77791..ef0f4b54f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -117,11 +117,14 @@ public abstract class DisplayRecord { public boolean isMissedCall() { return SmsDatabase.Types.isMissedCall(type); } + public boolean isFirstMissedCall() { + return SmsDatabase.Types.isFirstMissedCall(type); + } public boolean isDeleted() { return MmsSmsColumns.Types.isDeletedMessage(type); } public boolean isMessageRequestResponse() { return MmsSmsColumns.Types.isMessageRequestResponse(type); } public boolean isControlMessage() { return isGroupUpdateMessage() || isExpirationTimerUpdate() || isDataExtractionNotification() - || isMessageRequestResponse(); + || isMessageRequestResponse() || isCallLog(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 31db5b451..0f5247cf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -24,6 +24,7 @@ import android.text.style.StyleSpan; import androidx.annotation.NonNull; +import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; import org.session.libsession.messaging.utilities.UpdateMessageBuilder; import org.session.libsession.messaging.utilities.UpdateMessageData; @@ -112,6 +113,18 @@ public abstract class MessageRecord extends DisplayRecord { } else if (isDataExtractionNotification()) { if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); + } else if (isCallLog()) { + CallMessageType callType; + if (isIncomingCall()) { + callType = CallMessageType.CALL_INCOMING; + } else if (isOutgoingCall()) { + callType = CallMessageType.CALL_OUTGOING; + } else if (isMissedCall()) { + callType = CallMessageType.CALL_MISSED; + } else { + callType = CallMessageType.CALL_FIRST_MISSED; + } + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildCallMessage(context, callType, getIndividualRecipient().getAddress().serialize())); } return new SpannableString(getBody()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt new file mode 100644 index 000000000..da15c2f6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.dependencies + +import android.content.Context +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ServiceScoped +import dagger.hilt.components.SingletonComponent +import org.session.libsession.database.CallDataProvider +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CallModule { + + @Provides + @Singleton + fun provideAudioManagerCompat(@ApplicationContext context: Context) = AudioManagerCompat.create(context) + + @Provides + @Singleton + fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) = + CallManager(context, audioManagerCompat, storage) + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 0522dbcac..d70653f8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show + import java.io.IOException import java.util.Locale import javax.inject.Inject @@ -195,6 +196,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } this.broadcastReceiver = broadcastReceiver LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) + lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { // Double check that the long poller is up diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index 0b9a37971..697f6718c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -43,7 +43,7 @@ public class LinkPreviewRepository { private final OkHttpClient client; - public LinkPreviewRepository(@NonNull Context context) { + public LinkPreviewRepository() { this.client = new OkHttpClient.Builder() .addNetworkInterceptor(new ContentProxySafetyInterceptor()) .cache(null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java index a359596c9..549d6eac0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -25,7 +25,7 @@ public abstract class AbstractNotificationBuilder extends NotificationCompat.Bui @SuppressWarnings("unused") private static final String TAG = AbstractNotificationBuilder.class.getSimpleName(); - private static final int MAX_DISPLAY_LENGTH = 500; + private static final int MAX_DISPLAY_LENGTH = 50; protected Context context; protected NotificationPrivacyPreference privacy; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index efe75fef8..31186e24c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -259,10 +259,8 @@ public class DefaultMessageNotifier implements MessageNotifier { try { telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); - pushCursor = DatabaseComponent.get(context).pushDatabase().getPending(); - if (((telcoCursor == null || telcoCursor.isAfterLast()) && - (pushCursor == null || pushCursor.isAfterLast())) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) + if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { cancelActiveNotifications(context); updateBadge(context, 0); @@ -278,15 +276,19 @@ public class DefaultMessageNotifier implements MessageNotifier { lastAudibleNotification = System.currentTimeMillis(); } - if (notificationState.hasMultipleThreads()) { - for (long threadId : notificationState.getThreads()) { - sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); + try { + if (notificationState.hasMultipleThreads()) { + for (long threadId : notificationState.getThreads()) { + sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); + } + sendMultipleThreadNotification(context, notificationState, signal); + } else if (notificationState.getMessageCount() > 0){ + sendSingleThreadNotification(context, notificationState, signal, false); + } else { + cancelActiveNotifications(context); } - sendMultipleThreadNotification(context, notificationState, signal); - } else if (notificationState.getMessageCount() > 0){ - sendSingleThreadNotification(context, notificationState, signal, false); - } else { - cancelActiveNotifications(context); + } catch (Exception e) { + Log.e(TAG, "Error creating notification",e); } cancelOrphanedNotifications(context, notificationState); updateBadge(context, notificationState.getMessageCount()); @@ -296,10 +298,18 @@ public class DefaultMessageNotifier implements MessageNotifier { } } finally { if (telcoCursor != null) telcoCursor.close(); - if (pushCursor != null) pushCursor.close(); } } + private String getTrimmedText(CharSequence text) { + String trimmedText = ""; + if (text != null) { + int trimEnd = Math.min(text.length(), 50); + trimmedText = text.subSequence(0,trimEnd) + (text.length() > 50 ? "..." : ""); + } + return trimmedText; + } + private void sendSingleThreadNotification(@NonNull Context context, @NonNull NotificationState notificationState, boolean signal, boolean bundled) @@ -331,11 +341,14 @@ public class DefaultMessageNotifier implements MessageNotifier { builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); + CharSequence text = notifications.get(0).getText(); + String trimmedText = getTrimmedText(text); + builder.setThread(notifications.get(0).getRecipient()); builder.setMessageCount(notificationState.getMessageCount()); MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context); builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions(notifications.get(0).getText(), + MentionUtilities.highlightMentions(trimmedText, notifications.get(0).getThreadId(), context), notifications.get(0).getSlideDeck()); @@ -435,8 +448,8 @@ public class DefaultMessageNotifier implements MessageNotifier { builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); Notification notification = builder.build(); - NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, builder.build()); - Log.i(TAG, "Posted notification. " + notification.toString()); + NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification); + Log.i(TAG, "Posted notification. " + notification); } private void sendInThreadNotification(Context context, Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index 03da0f378..81332e87d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -93,7 +93,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu } if (privacy.isDisplayContact() && sender.getContactUri() != null) { - addPerson(sender.getContactUri().toString()); +// addPerson(sender.getContactUri().toString()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 68b69344b..61c126339 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.notifications; import android.annotation.TargetApi; +import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; @@ -44,14 +45,15 @@ public class NotificationChannels { private static final String TAG = NotificationChannels.class.getSimpleName(); private static final int VERSION_MESSAGES_CATEGORY = 2; + private static final int VERSION_SESSION_CALLS = 3; - private static final int VERSION = 2; + private static final int VERSION = 3; private static final String CATEGORY_MESSAGES = "messages"; private static final String CONTACT_PREFIX = "contact_"; private static final String MESSAGES_PREFIX = "messages_"; - public static final String CALLS = "calls_v2"; + public static final String CALLS = "calls_v3"; public static final String FAILURES = "failures"; public static final String APP_UPDATES = "app_updates"; public static final String BACKUPS = "backups_v2"; @@ -427,7 +429,7 @@ public class NotificationChannels { notificationManager.createNotificationChannelGroup(messagesGroup); NotificationChannel messages = new NotificationChannel(getMessagesChannel(context), context.getString(R.string.NotificationChannel_messages), NotificationManager.IMPORTANCE_HIGH); - NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.NotificationChannel_calls), NotificationManager.IMPORTANCE_LOW); + NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.NotificationChannel_calls), NotificationManager.IMPORTANCE_HIGH); NotificationChannel failures = new NotificationChannel(FAILURES, context.getString(R.string.NotificationChannel_failures), NotificationManager.IMPORTANCE_HIGH); NotificationChannel backups = new NotificationChannel(BACKUPS, context.getString(R.string.NotificationChannel_backups), NotificationManager.IMPORTANCE_LOW); NotificationChannel lockedStatus = new NotificationChannel(LOCKED_STATUS, context.getString(R.string.NotificationChannel_locked_status), NotificationManager.IMPORTANCE_LOW); @@ -439,6 +441,7 @@ public class NotificationChannels { setLedPreference(messages, TextSecurePreferences.getNotificationLedColor(context)); calls.setShowBadge(false); + calls.setSound(null, null); backups.setShowBadge(false); lockedStatus.setShowBadge(false); other.setShowBadge(false); @@ -463,6 +466,8 @@ public class NotificationChannels { notificationManager.deleteNotificationChannel("locked_status"); notificationManager.deleteNotificationChannel("backups"); notificationManager.deleteNotificationChannel("other"); + } if (oldVersion < VERSION_SESSION_CALLS) { + notificationManager.deleteNotificationChannel("calls_v2"); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index db93b739a..acac47e56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -295,11 +295,11 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil .asBitmap() .load(new DecryptableStreamUriLoader.DecryptableUri(uri)) .diskCacheStrategy(DiskCacheStrategy.NONE) - .submit(500, 500) + .submit(64, 64) .get(); } catch (InterruptedException | ExecutionException e) { Log.w(TAG, e); - return Bitmap.createBitmap(500, 500, Bitmap.Config.RGB_565); + return Bitmap.createBitmap(64, 64, Bitmap.Config.RGB_565); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java index 2e9e6b846..5c42f38bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -1,22 +1,33 @@ package org.thoughtcrime.securesms.preferences; +import android.Manifest; import android.app.Activity; +import android.app.AlertDialog; import android.app.KeyguardManager; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; +import android.provider.Settings; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; import androidx.preference.Preference; import org.session.libsession.utilities.TextSecurePreferences; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.CallNotificationBuilder; +import org.thoughtcrime.securesms.util.IntentUtils; import java.util.concurrent.TimeUnit; +import kotlin.jvm.functions.Function1; import mobi.upod.timedurationpicker.TimeDurationPickerDialog; +import network.loki.messenger.BuildConfig; import network.loki.messenger.R; public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment { @@ -36,10 +47,51 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener()); + this.findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED).setOnPreferenceChangeListener(new CallToggleListener(this, this::setCall)); initializeVisibility(); } + private Void setCall(boolean isEnabled) { + ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)).setChecked(isEnabled); + if (isEnabled && !CallNotificationBuilder.areNotificationsEnabled(requireActivity())) { + // show a dialog saying that calls won't work properly if you don't have notifications on at a system level + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.CallNotificationBuilder_system_notification_title) + .setMessage(R.string.CallNotificationBuilder_system_notification_message) + .setPositiveButton(R.string.activity_notification_settings_title, (d, w) -> { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Intent settingsIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID); + if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { + startActivity(settingsIntent); + } + } else { + Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setData(Uri.parse("package:"+BuildConfig.APPLICATION_ID)); + if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { + startActivity(settingsIntent); + } + } + d.dismiss(); + }) + .setNeutralButton(R.string.dismiss, (d, w) -> { + // do nothing, user might have broken notifications + d.dismiss(); + }) + .show(); + } + return null; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.preferences_app_protection); @@ -136,4 +188,52 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment return true; } } + + private class CallToggleListener implements Preference.OnPreferenceChangeListener { + + private final Fragment context; + private final Function1 setCallback; + + private CallToggleListener(Fragment context, Function1 setCallback) { + this.context = context; + this.setCallback = setCallback; + } + + private void requestMicrophonePermission() { + Permissions.with(context) + .request(Manifest.permission.RECORD_AUDIO) + .onAllGranted(() -> { + TextSecurePreferences.setBooleanPreference(context.requireContext(), TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, true); + setCallback.invoke(true); + }) + .onAnyDenied(() -> setCallback.invoke(false)) + .execute(); + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean val = (boolean) newValue; + if (val) { + // check if we've shown the info dialog and check for microphone permissions + if (TextSecurePreferences.setShownCallWarning(context.requireContext())) { + new AlertDialog.Builder(context.requireContext()) + .setTitle(R.string.dialog_voice_video_title) + .setMessage(R.string.dialog_voice_video_message) + .setPositiveButton(R.string.dialog_link_preview_enable_button_title, (d, w) -> { + requestMicrophonePermission(); + }) + .setNegativeButton(R.string.cancel, (d, w) -> { + + }) + .show(); + } else { + requestMicrophonePermission(); + } + return false; + } else { + return true; + } + } + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt new file mode 100644 index 000000000..079d7a21a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -0,0 +1,861 @@ +package org.thoughtcrime.securesms.service + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.IntentFilter +import android.media.AudioManager +import android.os.IBinder +import android.os.ResultReceiver +import android.telephony.PhoneStateListener +import android.telephony.TelephonyManager +import androidx.core.os.bundleOf +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.FutureTaskListener +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.calls.WebRtcCallActivity +import org.thoughtcrime.securesms.util.CallNotificationBuilder +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_ESTABLISHED +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_CONNECTING +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_PRE_OFFER +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_RINGING +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_OUTGOING_RINGING +import org.thoughtcrime.securesms.webrtc.AudioManagerCommand +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.CallViewModel +import org.thoughtcrime.securesms.webrtc.HangUpRtcOnPstnCallAnsweredListener +import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver +import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver +import org.thoughtcrime.securesms.webrtc.PeerConnectionException +import org.thoughtcrime.securesms.webrtc.PowerButtonReceiver +import org.thoughtcrime.securesms.webrtc.ProximityLockRelease +import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager +import org.thoughtcrime.securesms.webrtc.WiredHeadsetStateReceiver +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger +import org.thoughtcrime.securesms.webrtc.data.Event +import org.thoughtcrime.securesms.webrtc.locks.LockManager +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnection.IceConnectionState.CONNECTED +import org.webrtc.PeerConnection.IceConnectionState.DISCONNECTED +import org.webrtc.PeerConnection.IceConnectionState.FAILED +import org.webrtc.RtpReceiver +import org.webrtc.SessionDescription +import java.util.UUID +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import org.thoughtcrime.securesms.webrtc.data.State as CallState + +@AndroidEntryPoint +class WebRtcCallService: Service(), CallManager.WebRtcListener { + + companion object { + + private val TAG = Log.tag(WebRtcCallService::class.java) + + const val ACTION_INCOMING_RING = "RING_INCOMING" + const val ACTION_OUTGOING_CALL = "CALL_OUTGOING" + const val ACTION_ANSWER_CALL = "ANSWER_CALL" + const val ACTION_DENY_CALL = "DENY_CALL" + const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP" + const val ACTION_SET_MUTE_AUDIO = "SET_MUTE_AUDIO" + const val ACTION_SET_MUTE_VIDEO = "SET_MUTE_VIDEO" + const val ACTION_FLIP_CAMERA = "FLIP_CAMERA" + const val ACTION_UPDATE_AUDIO = "UPDATE_AUDIO" + const val ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE" + const val ACTION_SCREEN_OFF = "SCREEN_OFF" + const val ACTION_CHECK_TIMEOUT = "CHECK_TIMEOUT" + const val ACTION_CHECK_RECONNECT = "CHECK_RECONNECT" + const val ACTION_CHECK_RECONNECT_TIMEOUT = "CHECK_RECONNECT_TIMEOUT" + const val ACTION_IS_IN_CALL_QUERY = "IS_IN_CALL" + const val ACTION_WANTS_TO_ANSWER = "WANTS_TO_ANSWER" + + const val ACTION_PRE_OFFER = "PRE_OFFER" + const val ACTION_RESPONSE_MESSAGE = "RESPONSE_MESSAGE" + const val ACTION_ICE_MESSAGE = "ICE_MESSAGE" + const val ACTION_REMOTE_HANGUP = "REMOTE_HANGUP" + const val ACTION_ICE_CONNECTED = "ICE_CONNECTED" + + const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" + const val EXTRA_ENABLED = "ENABLED" + const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND" + const val EXTRA_MUTE = "mute_value" + const val EXTRA_AVAILABLE = "enabled_value" + const val EXTRA_REMOTE_DESCRIPTION = "remote_description" + const val EXTRA_TIMESTAMP = "timestamp" + const val EXTRA_CALL_ID = "call_id" + const val EXTRA_ICE_SDP = "ice_sdp" + const val EXTRA_ICE_SDP_MID = "ice_sdp_mid" + const val EXTRA_ICE_SDP_LINE_INDEX = "ice_sdp_line_index" + const val EXTRA_RESULT_RECEIVER = "result_receiver" + const val EXTRA_WANTS_TO_ANSWER = "wants_to_answer" + + const val INVALID_NOTIFICATION_ID = -1 + private const val TIMEOUT_SECONDS = 30L + private const val RECONNECT_SECONDS = 5L + private const val MAX_RECONNECTS = 5 + + fun cameraEnabled(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_SET_MUTE_VIDEO) + .putExtra(EXTRA_MUTE, !enabled) + + fun flipCamera(context: Context) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_FLIP_CAMERA) + + fun acceptCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_ANSWER_CALL) + + fun microphoneIntent(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_SET_MUTE_AUDIO) + .putExtra(EXTRA_MUTE, !enabled) + + fun createCall(context: Context, recipient: Recipient) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_OUTGOING_CALL) + .putExtra(EXTRA_RECIPIENT_ADDRESS, recipient.address) + + fun incomingCall(context: Context, address: Address, sdp: String, callId: UUID, callTime: Long) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_INCOMING_RING) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) + .putExtra(EXTRA_TIMESTAMP, callTime) + + fun incomingAnswer(context: Context, address: Address, sdp: String, callId: UUID) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_RESPONSE_MESSAGE) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) + + fun preOffer(context: Context, address: Address, callId: UUID, callTime: Long) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_PRE_OFFER) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_TIMESTAMP, callTime) + + fun iceCandidates(context: Context, address: Address, iceCandidates: List, callId: UUID) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_ICE_MESSAGE) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_ICE_SDP, iceCandidates.map(IceCandidate::sdp).toTypedArray()) + .putExtra(EXTRA_ICE_SDP_LINE_INDEX, iceCandidates.map(IceCandidate::sdpMLineIndex).toIntArray()) + .putExtra(EXTRA_ICE_SDP_MID, iceCandidates.map(IceCandidate::sdpMid).toTypedArray()) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + + fun denyCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java).setAction(ACTION_DENY_CALL) + + fun remoteHangupIntent(context: Context, callId: UUID) = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_REMOTE_HANGUP) + .putExtra(EXTRA_CALL_ID, callId) + + fun hangupIntent(context: Context) = Intent(context, WebRtcCallService::class.java).setAction(ACTION_LOCAL_HANGUP) + + fun sendAudioManagerCommand(context: Context, command: AudioManagerCommand) { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_UPDATE_AUDIO) + .putExtra(EXTRA_AUDIO_COMMAND, command) + context.startService(intent) + } + + fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) { + val intent = Intent(ACTION_WANTS_TO_ANSWER) + .putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer) + + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } + + @JvmStatic + fun isCallActive(context: Context, resultReceiver: ResultReceiver) { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_IS_IN_CALL_QUERY) + .putExtra(EXTRA_RESULT_RECEIVER, resultReceiver) + context.startService(intent) + } + } + + @Inject lateinit var callManager: CallManager + + private var wantsToAnswer = false + private var currentTimeouts = 0 + private var isNetworkAvailable = true + private var scheduledTimeout: ScheduledFuture<*>? = null + private var scheduledReconnect: ScheduledFuture<*>? = null + + private val lockManager by lazy { LockManager(this) } + private val serviceExecutor = Executors.newSingleThreadExecutor() + private val timeoutExecutor = Executors.newScheduledThreadPool(1) + private val hangupOnCallAnswered = HangUpRtcOnPstnCallAnsweredListener { + startService(hangupIntent(this)) + } + + private var networkChangedReceiver: NetworkChangeReceiver? = null + private var callReceiver: IncomingPstnCallReceiver? = null + private var wantsToAnswerReceiver: BroadcastReceiver? = null + private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null + private var uncaughtExceptionHandlerManager: UncaughtExceptionHandlerManager? = null + private var powerButtonReceiver: PowerButtonReceiver? = null + + @Synchronized + private fun terminate() { + LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(WebRtcCallActivity.ACTION_END)) + lockManager.updatePhoneState(LockManager.PhoneState.IDLE) + callManager.stop() + wantsToAnswer = false + currentTimeouts = 0 + isNetworkAvailable = true + scheduledTimeout?.cancel(false) + scheduledReconnect?.cancel(false) + scheduledTimeout = null + scheduledReconnect = null + stopForeground(true) + } + + private fun isSameCall(intent: Intent): Boolean { + val expectedCallId = getCallId(intent) + return callManager.callId == expectedCallId + } + + + private fun isPreOffer() = callManager.isPreOffer() + + private fun isBusy(intent: Intent) = callManager.isBusy(this, getCallId(intent)) + + private fun isIdle() = callManager.isIdle() + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onHangup() { + serviceExecutor.execute { + callManager.handleRemoteHangup() + + if (callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { + callManager.recipient?.let { recipient -> + insertMissedCall(recipient, true) + } + } + terminate() + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null || intent.action == null) return START_NOT_STICKY + serviceExecutor.execute { + val action = intent.action + Log.i("Loki", "Handling ${intent.action}") + when { + action == ACTION_INCOMING_RING && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleNewOffer(intent) + action == ACTION_PRE_OFFER && isIdle() -> handlePreOffer(intent) + action == ACTION_INCOMING_RING && isBusy(intent) -> handleBusyCall(intent) + action == ACTION_INCOMING_RING && isPreOffer() -> handleIncomingRing(intent) + action == ACTION_OUTGOING_CALL && isIdle() -> handleOutgoingCall(intent) + action == ACTION_ANSWER_CALL -> handleAnswerCall(intent) + action == ACTION_DENY_CALL -> handleDenyCall(intent) + action == ACTION_LOCAL_HANGUP -> handleLocalHangup(intent) + action == ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent) + action == ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent) + action == ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent) + action == ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) + action == ACTION_WIRED_HEADSET_CHANGE -> handleWiredHeadsetChanged(intent) + action == ACTION_SCREEN_OFF -> handleScreenOffChange(intent) + action == ACTION_RESPONSE_MESSAGE && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleResponseMessage(intent) + action == ACTION_RESPONSE_MESSAGE -> handleResponseMessage(intent) + action == ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) + action == ACTION_ICE_CONNECTED -> handleIceConnected(intent) + action == ACTION_CHECK_TIMEOUT -> handleCheckTimeout(intent) + action == ACTION_CHECK_RECONNECT -> handleCheckReconnect(intent) + action == ACTION_IS_IN_CALL_QUERY -> handleIsInCallQuery(intent) + action == ACTION_UPDATE_AUDIO -> handleUpdateAudio(intent) + } + } + return START_NOT_STICKY + } + + override fun onCreate() { + super.onCreate() + callManager.registerListener(this) + wantsToAnswer = false + isNetworkAvailable = true + registerIncomingPstnCallReceiver() + registerWiredHeadsetStateReceiver() + registerWantsToAnswerReceiver() + getSystemService(TelephonyManager::class.java) + .listen(hangupOnCallAnswered, PhoneStateListener.LISTEN_CALL_STATE) + registerUncaughtExceptionHandler() + networkChangedReceiver = NetworkChangeReceiver(::networkChange) + networkChangedReceiver!!.register(this) + } + + private fun registerUncaughtExceptionHandler() { + uncaughtExceptionHandlerManager = UncaughtExceptionHandlerManager().apply { + registerHandler(ProximityLockRelease(lockManager)) + } + } + + private fun registerIncomingPstnCallReceiver() { + callReceiver = IncomingPstnCallReceiver() + registerReceiver(callReceiver, IntentFilter("android.intent.action.PHONE_STATE")) + } + + private fun registerWantsToAnswerReceiver() { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + wantsToAnswer = intent?.getBooleanExtra(EXTRA_WANTS_TO_ANSWER, false) ?: false + } + } + wantsToAnswerReceiver = receiver + LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_WANTS_TO_ANSWER)) + } + + private fun registerWiredHeadsetStateReceiver() { + wiredHeadsetStateReceiver = WiredHeadsetStateReceiver() + registerReceiver(wiredHeadsetStateReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG)) + } + + private fun handleBusyCall(intent: Intent) { + val recipient = getRemoteRecipient(intent) + val callState = callManager.currentConnectionState + + insertMissedCall(recipient, false) + + if (callState == CallState.Idle) { + stopForeground(true) + } + } + + private fun handleUpdateAudio(intent: Intent) { + val audioCommand = intent.getParcelableExtra(EXTRA_AUDIO_COMMAND)!! + if (callManager.currentConnectionState !in arrayOf(CallState.Connected, *CallState.PENDING_CONNECTION_STATES)) { + Log.w(TAG, "handling audio command not in call") + return + } + callManager.handleAudioCommand(audioCommand) + } + + private fun handleNewOffer(intent: Intent) { + val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return + val callId = getCallId(intent) + val recipient = getRemoteRecipient(intent) + callManager.onNewOffer(offer, callId, recipient).fail { + Log.e("Loki", "Error handling new offer", it) + callManager.postConnectionError() + terminate() + } + } + + private fun handlePreOffer(intent: Intent) { + if (!callManager.isIdle()) { + Log.w(TAG, "Handling pre-offer from non-idle state") + return + } + val callId = getCallId(intent) + val recipient = getRemoteRecipient(intent) + + if (isIncomingMessageExpired(intent)) { + insertMissedCall(recipient, true) + terminate() + return + } + + callManager.onPreOffer(callId, recipient) { + setCallInProgressNotification(TYPE_INCOMING_PRE_OFFER, recipient) + callManager.postViewModelState(CallViewModel.State.CALL_PRE_INIT) + callManager.initializeAudioForCall() + callManager.startIncomingRinger() + callManager.setAudioEnabled(true) + } + } + + private fun handleIncomingRing(intent: Intent) { + val callId = getCallId(intent) + val recipient = getRemoteRecipient(intent) + val preOffer = callManager.preOfferCallData + + if (callManager.isPreOffer() && (preOffer == null || preOffer.callId != callId || preOffer.recipient != recipient)) { + Log.d(TAG, "Incoming ring from non-matching pre-offer") + return + } + + val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return + val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1) + + callManager.onIncomingRing(offer, callId, recipient, timestamp) { + if (wantsToAnswer) { + setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) + } else { + setCallInProgressNotification(TYPE_INCOMING_RINGING, recipient) + } + callManager.clearPendingIceUpdates() + callManager.postViewModelState(CallViewModel.State.CALL_RINGING) + registerPowerButtonReceiver() + } + } + + private fun handleOutgoingCall(intent: Intent) { + callManager.postConnectionEvent(Event.SendPreOffer) { + val recipient = getRemoteRecipient(intent) + callManager.recipient = recipient + val callId = UUID.randomUUID() + callManager.callId = callId + + callManager.initializeVideo(this) + + callManager.postViewModelState(CallViewModel.State.CALL_OUTGOING) + lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) + callManager.initializeAudioForCall() + callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) + setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient) + callManager.insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_OUTGOING) + scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + callManager.setAudioEnabled(true) + + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId + + try { + val offerFuture = callManager.onOutgoingCall(this) + offerFuture.fail { e -> + if (isConsistentState(expectedState, expectedCallId, callManager.currentConnectionState, callManager.callId)) { + Log.e(TAG,e) + callManager.postViewModelState(CallViewModel.State.NETWORK_FAILURE) + callManager.postConnectionError() + terminate() + } + } + } catch (e: Exception) { + Log.e(TAG,e) + callManager.postConnectionError() + terminate() + } + } + } + + private fun handleAnswerCall(intent: Intent) { + val recipient = callManager.recipient ?: return + val pending = callManager.pendingOffer ?: return + val callId = callManager.callId ?: return + val timestamp = callManager.pendingOfferTime + + if (callManager.currentConnectionState != CallState.RemoteRing) { + Log.e(TAG, "Can only answer from ringing!") + return + } + + intent.putExtra(EXTRA_CALL_ID, callId) + intent.putExtra(EXTRA_RECIPIENT_ADDRESS, recipient.address) + intent.putExtra(EXTRA_REMOTE_DESCRIPTION, pending) + intent.putExtra(EXTRA_TIMESTAMP, timestamp) + + if (isIncomingMessageExpired(intent)) { + val didHangup = callManager.postConnectionEvent(Event.TimeOut) { + insertMissedCall(recipient, true) + terminate() + } + if (didHangup) { + return + } + } + + callManager.postConnectionEvent(Event.SendAnswer) { + setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) + + callManager.silenceIncomingRinger() + callManager.postViewModelState(CallViewModel.State.CALL_INCOMING) + + scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + + callManager.initializeAudioForCall() + callManager.initializeVideo(this) + + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId + + try { + val answerFuture = callManager.onIncomingCall(this) + answerFuture.fail { e -> + if (isConsistentState(expectedState,expectedCallId, callManager.currentConnectionState, callManager.callId)) { + Log.e(TAG, e) + insertMissedCall(recipient, true) + callManager.postConnectionError() + terminate() + } + } + lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) + callManager.setAudioEnabled(true) + } catch (e: Exception) { + Log.e(TAG,e) + callManager.postConnectionError() + terminate() + } + } + } + + private fun handleDenyCall(intent: Intent) { + callManager.handleDenyCall() + terminate() + } + + private fun handleLocalHangup(intent: Intent) { + val intentRecipient = getOptionalRemoteRecipient(intent) + callManager.handleLocalHangup(intentRecipient) + terminate() + } + + private fun handleRemoteHangup(intent: Intent) { + if (callManager.callId != getCallId(intent)) { + Log.e(TAG, "Hangup for non-active call...") + return + } + + onHangup() + } + + private fun handleSetMuteAudio(intent: Intent) { + val muted = intent.getBooleanExtra(EXTRA_MUTE, false) + callManager.handleSetMuteAudio(muted) + } + + private fun handleSetMuteVideo(intent: Intent) { + val muted = intent.getBooleanExtra(EXTRA_MUTE, false) + callManager.handleSetMuteVideo(muted, lockManager) + } + + private fun handleSetCameraFlip(intent: Intent) { + callManager.handleSetCameraFlip() + } + + private fun handleWiredHeadsetChanged(intent: Intent) { + callManager.handleWiredHeadsetChanged(intent.getBooleanExtra(EXTRA_AVAILABLE, false)) + } + + private fun handleScreenOffChange(intent: Intent) { + callManager.handleScreenOffChange() + } + + private fun handleResponseMessage(intent: Intent) { + try { + val recipient = getRemoteRecipient(intent) + if (callManager.isCurrentUser(recipient) && callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { + handleLocalHangup(intent) + return + } + val callId = getCallId(intent) + val description = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) + callManager.handleResponseMessage(recipient, callId, SessionDescription(SessionDescription.Type.ANSWER, description)) + } catch (e: PeerConnectionException) { + terminate() + } + } + + private fun handleRemoteIceCandidate(intent: Intent) { + val callId = getCallId(intent) + val sdpMids = intent.getStringArrayExtra(EXTRA_ICE_SDP_MID) ?: return + val sdpLineIndexes = intent.getIntArrayExtra(EXTRA_ICE_SDP_LINE_INDEX) ?: return + val sdps = intent.getStringArrayExtra(EXTRA_ICE_SDP) ?: return + if (sdpMids.size != sdpLineIndexes.size || sdpLineIndexes.size != sdps.size) { + Log.w(TAG,"sdp info not of equal length") + return + } + val iceCandidates = sdpMids.indices.map { index -> + IceCandidate( + sdpMids[index], + sdpLineIndexes[index], + sdps[index] + ) + } + callManager.handleRemoteIceCandidate(iceCandidates, callId) + } + + private fun handleIceConnected(intent: Intent) { + val recipient = callManager.recipient ?: return + val connected = callManager.postConnectionEvent(Event.Connect) { + callManager.postViewModelState(CallViewModel.State.CALL_CONNECTED) + setCallInProgressNotification(TYPE_ESTABLISHED, recipient) + callManager.startCommunication(lockManager) + } + if (!connected) { + Log.e("Loki", "Error handling ice connected state transition") + callManager.postConnectionError() + terminate() + } + } + + private fun handleIsInCallQuery(intent: Intent) { + val listener = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) ?: return + val currentState = callManager.currentConnectionState + val isInCall = if (currentState in arrayOf(*CallState.PENDING_CONNECTION_STATES, CallState.Connected)) 1 else 0 + listener.send(isInCall, bundleOf()) + } + + private fun registerPowerButtonReceiver() { + if (powerButtonReceiver == null) { + powerButtonReceiver = PowerButtonReceiver() + + registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) + } + } + + private fun handleCheckReconnect(intent: Intent) { + val callId = callManager.callId ?: return + val numTimeouts = ++currentTimeouts + + if (callId == getCallId(intent) && isNetworkAvailable && numTimeouts <= MAX_RECONNECTS) { + Log.i("Loki", "Trying to re-connect") + callManager.networkReestablished() + scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + } else if (numTimeouts < MAX_RECONNECTS) { + Log.i("Loki", "Network isn't available, timeouts == $numTimeouts out of $MAX_RECONNECTS") + scheduledReconnect = timeoutExecutor.schedule(CheckReconnectedRunnable(callId, this), RECONNECT_SECONDS, TimeUnit.SECONDS) + } else { + Log.i("Loki", "Network isn't available, timing out") + handleLocalHangup(intent) + } + } + + + + private fun handleCheckTimeout(intent: Intent) { + val callId = callManager.callId ?: return + val callState = callManager.currentConnectionState + + if (callId == getCallId(intent) && (callState !in arrayOf(CallState.Connected, CallState.Connecting))) { + Log.w(TAG, "Timing out call: $callId") + handleLocalHangup(intent) + } + } + + private fun setCallInProgressNotification(type: Int, recipient: Recipient?) { + startForeground( + CallNotificationBuilder.WEBRTC_NOTIFICATION, + CallNotificationBuilder.getCallInProgressNotification(this, type, recipient) + ) + if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) { + // start an intent for the fullscreen + val foregroundIntent = Intent(this, WebRtcCallActivity::class.java) + .setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT) + .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) + startActivity(foregroundIntent) + } + } + + private fun getOptionalRemoteRecipient(intent: Intent): Recipient? = + if (intent.hasExtra(EXTRA_RECIPIENT_ADDRESS)) { + getRemoteRecipient(intent) + } else { + null + } + + private fun getRemoteRecipient(intent: Intent): Recipient { + val remoteAddress = intent.getParcelableExtra
(EXTRA_RECIPIENT_ADDRESS) + ?: throw AssertionError("No recipient in intent!") + + return Recipient.from(this, remoteAddress, true) + } + + private fun getCallId(intent: Intent) : UUID { + return intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID + ?: throw AssertionError("No callId in intent!") + } + + private fun insertMissedCall(recipient: Recipient, signal: Boolean) { + callManager.insertCallMessage( + threadPublicKey = recipient.address.serialize(), + callMessageType = CallMessageType.CALL_MISSED, + signal = signal + ) + } + + private fun isIncomingMessageExpired(intent: Intent) = + System.currentTimeMillis() - intent.getLongExtra(EXTRA_TIMESTAMP, -1) > TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS) + + override fun onDestroy() { + Log.d(TAG,"onDestroy()") + callManager.unregisterListener(this) + callReceiver?.let { receiver -> + unregisterReceiver(receiver) + } + networkChangedReceiver?.unregister(this) + wantsToAnswerReceiver?.let { receiver -> + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) + } + networkChangedReceiver = null + callReceiver = null + uncaughtExceptionHandlerManager?.unregister() + wantsToAnswer = false + currentTimeouts = 0 + isNetworkAvailable = false + super.onDestroy() + } + + private fun networkChange(networkAvailable: Boolean) { + Log.d("Loki", "flipping network available to $networkAvailable") + isNetworkAvailable = networkAvailable + if (networkAvailable && !callManager.isReestablishing && callManager.currentConnectionState == CallState.Connected) { + Log.d("Loki", "Should reconnected") + } + } + + private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context): Runnable { + override fun run() { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_CHECK_RECONNECT) + .putExtra(EXTRA_CALL_ID, callId) + context.startService(intent) + } + } + + private class ReconnectTimeoutRunnable(private val callId: UUID, private val context: Context): Runnable { + override fun run() { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_CHECK_RECONNECT_TIMEOUT) + .putExtra(EXTRA_CALL_ID, callId) + context.startService(intent) + } + } + + private class TimeoutRunnable(private val callId: UUID, private val context: Context): Runnable { + override fun run() { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_CHECK_TIMEOUT) + .putExtra(EXTRA_CALL_ID, callId) + context.startService(intent) + } + } + + private abstract class FailureListener( + expectedState: CallState, + expectedCallId: UUID?, + getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { + override fun onSuccessContinue(result: V) {} + } + + private abstract class SuccessOnlyListener( + expectedState: CallState, + expectedCallId: UUID?, + getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { + override fun onFailureContinue(throwable: Throwable?) { + Log.e(TAG, throwable) + throw AssertionError(throwable) + } + } + + private abstract class StateAwareListener( + private val expectedState: CallState, + private val expectedCallId: UUID?, + private val getState: ()->Pair): FutureTaskListener { + + companion object { + private val TAG = Log.tag(StateAwareListener::class.java) + } + + override fun onSuccess(result: V) { + if (!isConsistentState()) { + Log.w(TAG,"State has changed since request, aborting success callback...") + } else { + onSuccessContinue(result) + } + } + + override fun onFailure(exception: ExecutionException?) { + if (!isConsistentState()) { + Log.w(TAG, exception) + Log.w(TAG,"State has changed since request, aborting failure callback...") + } else { + exception?.let { + onFailureContinue(it.cause) + } + } + } + + private fun isConsistentState(): Boolean { + val (currentState, currentCallId) = getState() + return expectedState == currentState && expectedCallId == currentCallId + } + + abstract fun onSuccessContinue(result: V) + abstract fun onFailureContinue(throwable: Throwable?) + + } + + private fun isConsistentState( + expectedState: CallState, + expectedCallId: UUID?, + currentState: CallState, + currentCallId: UUID? + ): Boolean { + return expectedState == currentState && expectedCallId == currentCallId + } + + override fun onSignalingChange(p0: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + newState?.let { state -> processIceConnectionChange(state) } + } + + private fun processIceConnectionChange(newState: PeerConnection.IceConnectionState) { + serviceExecutor.execute { + if (newState == CONNECTED) { + scheduledTimeout?.cancel(false) + scheduledReconnect?.cancel(false) + scheduledTimeout = null + scheduledReconnect = null + + val intent = Intent(this, WebRtcCallService::class.java) + .setAction(ACTION_ICE_CONNECTED) + startService(intent) + } else if (newState in arrayOf(FAILED, DISCONNECTED) && (scheduledReconnect == null && scheduledTimeout == null)) { + callManager.callId?.let { callId -> + callManager.postConnectionEvent(Event.IceDisconnect) { + callManager.postViewModelState(CallViewModel.State.CALL_RECONNECTING) + if (callManager.isInitiator()) { + Log.i("Loki", "Starting reconnect timer") + scheduledReconnect = timeoutExecutor.schedule(CheckReconnectedRunnable(callId, this), RECONNECT_SECONDS, TimeUnit.SECONDS) + } else { + Log.i("Loki", "Starting timeout, awaiting new reconnect") + callManager.postConnectionEvent(Event.PrepareForNewOffer) { + scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } + } + } ?: run { + val intent = hangupIntent(this) + startService(intent) + } + } + Log.i("Loki", "onIceConnectionChange: $newState") + } + } + + override fun onIceConnectionReceivingChange(p0: Boolean) {} + + override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {} + + override fun onIceCandidate(p0: IceCandidate?) {} + + override fun onIceCandidatesRemoved(p0: Array?) {} + + override fun onAddStream(p0: MediaStream?) {} + + override fun onRemoveStream(p0: MediaStream?) {} + + override fun onDataChannel(p0: DataChannel?) {} + + override fun onRenegotiationNeeded() { + Log.w(TAG,"onRenegotiationNeeded was called!") + } + + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt new file mode 100644 index 000000000..56c0a55dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.util + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.os.Build +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import network.loki.messenger.R +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.calls.WebRtcCallActivity +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.preferences.SettingsActivity +import org.thoughtcrime.securesms.service.WebRtcCallService + +class CallNotificationBuilder { + + companion object { + const val WEBRTC_NOTIFICATION = 313388 + + const val TYPE_INCOMING_RINGING = 1 + const val TYPE_OUTGOING_RINGING = 2 + const val TYPE_ESTABLISHED = 3 + const val TYPE_INCOMING_CONNECTING = 4 + const val TYPE_INCOMING_PRE_OFFER = 5 + + @JvmStatic + fun areNotificationsEnabled(context: Context): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return when { + !notificationManager.areNotificationsEnabled() -> false + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + notificationManager.notificationChannels.firstOrNull { channel -> + channel.importance == NotificationManager.IMPORTANCE_NONE + } == null + } + else -> true + } + } + + @JvmStatic + fun getFirstCallNotification(context: Context): Notification { + val contentIntent = Intent(context, SettingsActivity::class.java) + + val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val text = context.getString(R.string.CallNotificationBuilder_first_call_message) + + val builder = NotificationCompat.Builder(context, NotificationChannels.CALLS) + .setSound(null) + .setSmallIcon(R.drawable.ic_baseline_call_24) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentTitle(context.getString(R.string.CallNotificationBuilder_first_call_title)) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setAutoCancel(true) + + return builder.build() + } + + @JvmStatic + fun getCallInProgressNotification(context: Context, type: Int, recipient: Recipient?): Notification { + val contentIntent = Intent(context, WebRtcCallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + + val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(context, NotificationChannels.CALLS) + .setSound(null) + .setSmallIcon(R.drawable.ic_baseline_call_24) + .setContentIntent(pendingIntent) + .setOngoing(true) + + + recipient?.name?.let { name -> + builder.setContentTitle(name) + } + + when (type) { + TYPE_INCOMING_CONNECTING -> { + builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting)) + .setNotificationSilent() + } + TYPE_INCOMING_PRE_OFFER, + TYPE_INCOMING_RINGING -> { + builder.setContentText(context.getString(R.string.NotificationBarManager__incoming_signal_call)) + .setCategory(NotificationCompat.CATEGORY_CALL) + builder.addAction(getServiceNotificationAction( + context, + WebRtcCallService.ACTION_DENY_CALL, + R.drawable.ic_close_grey600_32dp, + R.string.NotificationBarManager__deny_call + )) + // if notifications aren't enabled, we will trigger the intent from WebRtcCallService + builder.setFullScreenIntent(getFullScreenPendingIntent( + context + ), true) + builder.addAction(getActivityNotificationAction( + context, + if (type == TYPE_INCOMING_PRE_OFFER) WebRtcCallActivity.ACTION_PRE_OFFER else WebRtcCallActivity.ACTION_ANSWER, + R.drawable.ic_phone_grey600_32dp, + R.string.NotificationBarManager__answer_call + )) + builder.priority = NotificationCompat.PRIORITY_MAX + } + TYPE_OUTGOING_RINGING -> { + builder.setContentText(context.getString(R.string.NotificationBarManager__establishing_signal_call)) + builder.addAction(getServiceNotificationAction( + context, + WebRtcCallService.ACTION_LOCAL_HANGUP, + R.drawable.ic_call_end_grey600_32dp, + R.string.NotificationBarManager__cancel_call + )) + } + else -> { + builder.setContentText(context.getString(R.string.NotificationBarManager_call_in_progress)) + builder.addAction(getServiceNotificationAction( + context, + WebRtcCallService.ACTION_LOCAL_HANGUP, + R.drawable.ic_call_end_grey600_32dp, + R.string.NotificationBarManager__end_call + )).setUsesChronometer(true) + } + } + + return builder.build() + } + + private fun getServiceNotificationAction(context: Context, action: String, iconResId: Int, titleResId: Int): NotificationCompat.Action { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(action) + + val pendingIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + return NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent) + } + + private fun getFullScreenPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, WebRtcCallActivity::class.java) + .setFlags(FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT) + .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) + + return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun getActivityNotificationAction(context: Context, action: String, + @DrawableRes iconResId: Int, @StringRes titleResId: Int): NotificationCompat.Action { + val intent = Intent(context, WebRtcCallActivity::class.java) + .setAction(action) + + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + return NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt new file mode 100644 index 000000000..b01edfb49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.webrtc + +enum class AudioEvent { + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt new file mode 100644 index 000000000..c6dd6525e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.webrtc + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager + +@Parcelize +open class AudioManagerCommand: Parcelable { + @Parcelize + object Initialize: AudioManagerCommand() + + @Parcelize + object UpdateAudioDeviceState: AudioManagerCommand() + + @Parcelize + data class StartOutgoingRinger(val type: OutgoingRinger.Type): AudioManagerCommand() + + @Parcelize + object SilenceIncomingRinger: AudioManagerCommand() + + @Parcelize + object Start: AudioManagerCommand() + + @Parcelize + data class Stop(val playDisconnect: Boolean): AudioManagerCommand() + + @Parcelize + data class StartIncomingRinger(val vibrate: Boolean): AudioManagerCommand() + + @Parcelize + data class SetUserDevice(val device: SignalAudioManager.AudioDevice): AudioManagerCommand() + + @Parcelize + data class SetDefaultDevice(val device: SignalAudioManager.AudioDevice, + val clearUserEarpieceSelection: Boolean): AudioManagerCommand() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt new file mode 100644 index 000000000..006da2b63 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -0,0 +1,718 @@ +package org.thoughtcrime.securesms.webrtc + +import android.content.Context +import android.telephony.TelephonyManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.bind +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Debouncer +import org.session.libsession.utilities.Util +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate +import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled +import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate +import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoEnabled +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice +import org.thoughtcrime.securesms.webrtc.data.Event +import org.thoughtcrime.securesms.webrtc.data.StateProcessor +import org.thoughtcrime.securesms.webrtc.locks.LockManager +import org.thoughtcrime.securesms.webrtc.video.CameraEventListener +import org.thoughtcrime.securesms.webrtc.video.CameraState +import org.thoughtcrime.securesms.webrtc.video.RemoteRotationVideoProxySink +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnection.IceConnectionState +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceViewRenderer +import java.nio.ByteBuffer +import java.util.ArrayDeque +import java.util.UUID +import org.thoughtcrime.securesms.webrtc.data.State as CallState + +class CallManager(context: Context, audioManager: AudioManagerCompat, private val storage: StorageProtocol): PeerConnection.Observer, + SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { + + sealed class StateEvent { + data class AudioEnabled(val isEnabled: Boolean): StateEvent() + data class VideoEnabled(val isEnabled: Boolean): StateEvent() + data class CallStateUpdate(val state: CallState): StateEvent() + data class AudioDeviceUpdate(val selectedDevice: AudioDevice, val audioDevices: Set): StateEvent() + data class RecipientUpdate(val recipient: Recipient?): StateEvent() { + companion object { + val UNKNOWN = RecipientUpdate(recipient = null) + } + } + } + + companion object { + val VIDEO_DISABLED_JSON by lazy { buildJsonObject { put("video", false) } } + val VIDEO_ENABLED_JSON by lazy { buildJsonObject { put("video", true) } } + val HANGUP_JSON by lazy { buildJsonObject { put("hangup", true) } } + + private val TAG = Log.tag(CallManager::class.java) + private const val DATA_CHANNEL_NAME = "signaling" + } + + private val signalAudioManager: SignalAudioManager = SignalAudioManager(context, this, audioManager) + + private val peerConnectionObservers = mutableSetOf() + + fun registerListener(listener: WebRtcListener) { + peerConnectionObservers.add(listener) + } + + fun unregisterListener(listener: WebRtcListener) { + peerConnectionObservers.remove(listener) + } + + private val _audioEvents = MutableStateFlow(AudioEnabled(false)) + val audioEvents = _audioEvents.asSharedFlow() + private val _videoEvents = MutableStateFlow(VideoEnabled(false)) + val videoEvents = _videoEvents.asSharedFlow() + private val _remoteVideoEvents = MutableStateFlow(VideoEnabled(false)) + val remoteVideoEvents = _remoteVideoEvents.asSharedFlow() + + private val stateProcessor = StateProcessor(CallState.Idle) + + private val _callStateEvents = MutableStateFlow(CallViewModel.State.CALL_PENDING) + val callStateEvents = _callStateEvents.asSharedFlow() + private val _recipientEvents = MutableStateFlow(RecipientUpdate.UNKNOWN) + val recipientEvents = _recipientEvents.asSharedFlow() + private var localCameraState: CameraState = CameraState.UNKNOWN + + private val _audioDeviceEvents = MutableStateFlow(AudioDeviceUpdate(AudioDevice.NONE, setOf())) + val audioDeviceEvents = _audioDeviceEvents.asSharedFlow() + + val currentConnectionState + get() = stateProcessor.currentState + + val currentCallState + get() = _callStateEvents.value + + var iceState = IceConnectionState.CLOSED + + private var eglBase: EglBase? = null + + var pendingOffer: String? = null + var pendingOfferTime: Long = -1 + var preOfferCallData: PreOffer? = null + var callId: UUID? = null + var recipient: Recipient? = null + set(value) { + field = value + _recipientEvents.value = RecipientUpdate(value) + } + var callStartTime: Long = -1 + var isReestablishing: Boolean = false + + private var peerConnection: PeerConnectionWrapper? = null + private var dataChannel: DataChannel? = null + + private val pendingOutgoingIceUpdates = ArrayDeque() + private val pendingIncomingIceUpdates = ArrayDeque() + + private val outgoingIceDebouncer = Debouncer(200L) + + var localRenderer: SurfaceViewRenderer? = null + var remoteRotationSink: RemoteRotationVideoProxySink? = null + var remoteRenderer: SurfaceViewRenderer? = null + private var peerConnectionFactory: PeerConnectionFactory? = null + + fun clearPendingIceUpdates() { + pendingOutgoingIceUpdates.clear() + pendingIncomingIceUpdates.clear() + } + + fun initializeAudioForCall() { + signalAudioManager.handleCommand(AudioManagerCommand.Initialize) + } + + fun startOutgoingRinger(ringerType: OutgoingRinger.Type) { + if (ringerType == OutgoingRinger.Type.RINGING) { + signalAudioManager.handleCommand(AudioManagerCommand.UpdateAudioDeviceState) + } + signalAudioManager.handleCommand(AudioManagerCommand.StartOutgoingRinger(ringerType)) + } + + fun silenceIncomingRinger() { + signalAudioManager.handleCommand(AudioManagerCommand.SilenceIncomingRinger) + } + + fun postConnectionEvent(transition: Event, onSuccess: ()->Unit): Boolean { + return stateProcessor.processEvent(transition, onSuccess) + } + + fun postConnectionError(): Boolean { + return stateProcessor.processEvent(Event.Error) + } + + fun postViewModelState(newState: CallViewModel.State) { + Log.d("Loki", "Posting view model state $newState") + _callStateEvents.value = newState + } + + fun isBusy(context: Context, callId: UUID) = callId != this.callId && (currentConnectionState != CallState.Idle + || context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE) + + fun isPreOffer() = currentConnectionState == CallState.RemotePreOffer + + fun isIdle() = currentConnectionState == CallState.Idle + + fun isCurrentUser(recipient: Recipient) = recipient.address.serialize() == storage.getUserPublicKey() + + fun initializeVideo(context: Context) { + Util.runOnMainSync { + val base = EglBase.create() + eglBase = base + localRenderer = SurfaceViewRenderer(context).apply { +// setScalingType(SCALE_ASPECT_FIT) + } + + remoteRenderer = SurfaceViewRenderer(context).apply { +// setScalingType(SCALE_ASPECT_FIT) + } + remoteRotationSink = RemoteRotationVideoProxySink() + + + localRenderer?.init(base.eglBaseContext, null) + localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + remoteRenderer?.init(base.eglBaseContext, null) + remoteRotationSink!!.setSink(remoteRenderer!!) + + val encoderFactory = DefaultVideoEncoderFactory(base.eglBaseContext, true, true) + val decoderFactory = DefaultVideoDecoderFactory(base.eglBaseContext) + + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(object: PeerConnectionFactory.Options() { + init { + networkIgnoreMask = 1 shl 4 + } + }) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory() + } + } + + fun callEnded() { + peerConnection?.dispose() + peerConnection = null + } + + fun setAudioEnabled(isEnabled: Boolean) { + currentConnectionState.withState(*CallState.CAN_HANGUP_STATES) { + peerConnection?.setAudioEnabled(isEnabled) + _audioEvents.value = AudioEnabled(true) + } + } + + override fun onSignalingChange(newState: PeerConnection.SignalingState) { + peerConnectionObservers.forEach { listener -> listener.onSignalingChange(newState) } + } + + override fun onIceConnectionChange(newState: IceConnectionState) { + Log.d("Loki", "New ice connection state = $newState") + iceState = newState + peerConnectionObservers.forEach { listener -> listener.onIceConnectionChange(newState) } + if (newState == IceConnectionState.CONNECTED) { + callStartTime = System.currentTimeMillis() + } + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) { + peerConnectionObservers.forEach { listener -> listener.onIceConnectionReceivingChange(receiving) } + } + + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) { + peerConnectionObservers.forEach { listener -> listener.onIceGatheringChange(newState) } + } + + override fun onIceCandidate(iceCandidate: IceCandidate) { + peerConnectionObservers.forEach { listener -> listener.onIceCandidate(iceCandidate) } + val expectedCallId = this.callId ?: return + val expectedRecipient = this.recipient ?: return + pendingOutgoingIceUpdates.add(iceCandidate) + + if (peerConnection?.readyForIce != true) return + + queueOutgoingIce(expectedCallId, expectedRecipient) + } + + private fun queueOutgoingIce(expectedCallId: UUID, expectedRecipient: Recipient) { + outgoingIceDebouncer.publish { + val currentCallId = this.callId ?: return@publish + val currentRecipient = this.recipient ?: return@publish + if (currentCallId == expectedCallId && expectedRecipient == currentRecipient) { + val currentPendings = mutableSetOf() + while (pendingOutgoingIceUpdates.isNotEmpty()) { + currentPendings.add(pendingOutgoingIceUpdates.pop()) + } + val sdps = currentPendings.map { it.sdp } + val sdpMLineIndexes = currentPendings.map { it.sdpMLineIndex } + val sdpMids = currentPendings.map { it.sdpMid } + + MessageSender.sendNonDurably(CallMessage( + ICE_CANDIDATES, + sdps = sdps, + sdpMLineIndexes = sdpMLineIndexes, + sdpMids = sdpMids, + currentCallId + ), currentRecipient.address) + } + } + } + + override fun onIceCandidatesRemoved(candidates: Array?) { + peerConnectionObservers.forEach { listener -> listener.onIceCandidatesRemoved(candidates) } + } + + override fun onAddStream(stream: MediaStream) { + peerConnectionObservers.forEach { listener -> listener.onAddStream(stream) } + for (track in stream.audioTracks) { + track.setEnabled(true) + } + + if (stream.videoTracks != null && stream.videoTracks.size == 1) { + val videoTrack = stream.videoTracks.first() + videoTrack.setEnabled(true) + videoTrack.addSink(remoteRotationSink) + } + } + + override fun onRemoveStream(p0: MediaStream?) { + peerConnectionObservers.forEach { listener -> listener.onRemoveStream(p0) } + } + + override fun onDataChannel(p0: DataChannel?) { + peerConnectionObservers.forEach { listener -> listener.onDataChannel(p0) } + } + + override fun onRenegotiationNeeded() { + peerConnectionObservers.forEach { listener -> listener.onRenegotiationNeeded() } + } + + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) { + peerConnectionObservers.forEach { listener -> listener.onAddTrack(p0, p1) } + } + + override fun onBufferedAmountChange(l: Long) { + Log.i(TAG,"onBufferedAmountChange: $l") + } + + override fun onStateChange() { + Log.i(TAG,"onStateChange") + } + + override fun onMessage(buffer: DataChannel.Buffer?) { + Log.i(TAG,"onMessage...") + buffer ?: return + + try { + val byteArray = ByteArray(buffer.data.remaining()) { buffer.data[it] } + val json = Json.parseToJsonElement(byteArray.decodeToString()) as JsonObject + if (json.containsKey("video")) { + _remoteVideoEvents.value = VideoEnabled((json["video"] as JsonPrimitive).boolean) + } else if (json.containsKey("hangup")) { + peerConnectionObservers.forEach(WebRtcListener::onHangup) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to deserialize data channel message", e) + } + } + + override fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set) { + _audioDeviceEvents.value = AudioDeviceUpdate(activeDevice, devices) + } + + fun stop() { + val isOutgoing = currentConnectionState in CallState.OUTGOING_STATES + stateProcessor.processEvent(Event.Cleanup) { + signalAudioManager.handleCommand(AudioManagerCommand.Stop(isOutgoing)) + peerConnection?.dispose() + peerConnection = null + + localRenderer?.release() + remoteRotationSink?.release() + remoteRenderer?.release() + eglBase?.release() + + localRenderer = null + remoteRenderer = null + eglBase = null + + localCameraState = CameraState.UNKNOWN + recipient = null + callId = null + pendingOfferTime = -1 + pendingOffer = null + callStartTime = -1 + _audioEvents.value = AudioEnabled(false) + _videoEvents.value = VideoEnabled(false) + _remoteVideoEvents.value = VideoEnabled(false) + pendingOutgoingIceUpdates.clear() + pendingIncomingIceUpdates.clear() + } + } + + override fun onCameraSwitchCompleted(newCameraState: CameraState) { + localCameraState = newCameraState + } + + fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { + stateProcessor.processEvent(Event.ReceivePreOffer) { + if (preOfferCallData != null) { + Log.d(TAG, "Received new pre-offer when we are already expecting one") + } + this.recipient = recipient + this.callId = callId + preOfferCallData = PreOffer(callId, recipient) + onSuccess() + } + } + + fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise { + if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) + if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) + + val connection = peerConnection ?: return Promise.ofFail(NullPointerException("No peer connection wrapper")) + + val reconnected = stateProcessor.processEvent(Event.ReceiveOffer) && stateProcessor.processEvent(Event.SendAnswer) + return if (reconnected) { + Log.i("Loki", "Handling new offer, restarting ice session") + connection.setNewRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) + // re-established an ice + val answer = connection.createAnswer(MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) + }) + connection.setLocalDescription(answer) + pendingIncomingIceUpdates.toList().forEach { update -> + connection.addIceCandidate(update) + } + pendingIncomingIceUpdates.clear() + val answerMessage = CallMessage.answer(answer.description, callId) + Log.i("Loki", "Posting new answer") + MessageSender.sendNonDurably(answerMessage, recipient.address) + } else { + Promise.ofFail(Exception("Couldn't reconnect from current state")) + } + } + + fun onIncomingRing(offer: String, callId: UUID, recipient: Recipient, callTime: Long, onSuccess: () -> Unit) { + postConnectionEvent(Event.ReceiveOffer) { + this.callId = callId + this.recipient = recipient + this.pendingOffer = offer + this.pendingOfferTime = callTime + initializeAudioForCall() + startIncomingRinger() + onSuccess() + } + } + + fun onIncomingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) + val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) + val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null")) + val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) + val local = localRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) + val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) + val connection = PeerConnectionWrapper( + context, + factory, + this, + local, + this, + base, + isAlwaysTurn + ) + peerConnection = connection + localCameraState = connection.getCameraState() + val dataChannel = connection.createDataChannel(DATA_CHANNEL_NAME) + this.dataChannel = dataChannel + dataChannel.registerObserver(this) + connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) + val answer = connection.createAnswer(MediaConstraints()) + connection.setLocalDescription(answer) + val answerMessage = CallMessage.answer(answer.description, callId) + val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) + MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress)) + val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( + answer.description, + callId + ), recipient.address) + + insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) + + while (pendingIncomingIceUpdates.isNotEmpty()) { + val candidate = pendingIncomingIceUpdates.pop() ?: break + connection.addIceCandidate(candidate) + } + return sendAnswerMessage.success { + pendingOffer = null + pendingOfferTime = -1 + } + } + + fun onOutgoingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) + val recipient = recipient + ?: return Promise.ofFail(NullPointerException("recipient is null")) + val factory = peerConnectionFactory + ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) + val local = localRenderer + ?: return Promise.ofFail(NullPointerException("localRenderer is null")) + val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) + + val sentOffer = stateProcessor.processEvent(Event.SendOffer) + + if (!sentOffer) { + return Promise.ofFail(Exception("Couldn't transition to sent offer state")) + } else { + val connection = PeerConnectionWrapper( + context, + factory, + this, + local, + this, + base, + isAlwaysTurn + ) + + peerConnection = connection + localCameraState = connection.getCameraState() + val dataChannel = connection.createDataChannel(DATA_CHANNEL_NAME) + dataChannel.registerObserver(this) + this.dataChannel = dataChannel + val offer = connection.createOffer(MediaConstraints()) + connection.setLocalDescription(offer) + + Log.d("Loki", "Sending pre-offer") + return MessageSender.sendNonDurably(CallMessage.preOffer( + callId + ), recipient.address).bind { + Log.d("Loki", "Sent pre-offer") + Log.d("Loki", "Sending offer") + MessageSender.sendNonDurably(CallMessage.offer( + offer.description, + callId + ), recipient.address).success { + Log.d("Loki", "Sent offer") + }.fail { + Log.e("Loki", "Failed to send offer", it) + } + } + } + } + + fun handleDenyCall() { + val callId = callId ?: return + val recipient = recipient ?: return + val userAddress = storage.getUserPublicKey() ?: return + stateProcessor.processEvent(Event.DeclineCall) { + MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) + } + } + + fun handleLocalHangup(intentRecipient: Recipient?) { + val recipient = recipient ?: return + val callId = callId ?: return + + val currentUserPublicKey = storage.getUserPublicKey() + val sendHangup = intentRecipient == null || (intentRecipient == recipient && recipient.address.serialize() != currentUserPublicKey) + + postViewModelState(CallViewModel.State.CALL_DISCONNECTED) + stateProcessor.processEvent(Event.Hangup) + if (sendHangup) { + dataChannel?.let { channel -> + val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) + channel.send(buffer) + } + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + } + } + + fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = System.currentTimeMillis()) { + storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp) + } + + fun handleRemoteHangup() { + when (currentConnectionState) { + CallState.LocalRing, + CallState.RemoteRing -> postViewModelState(CallViewModel.State.RECIPIENT_UNAVAILABLE) + else -> postViewModelState(CallViewModel.State.CALL_DISCONNECTED) + } + if (!stateProcessor.processEvent(Event.Hangup)) { + Log.e("Loki", "") + stateProcessor.processEvent(Event.Error) + } + } + + fun handleSetMuteAudio(muted: Boolean) { + _audioEvents.value = AudioEnabled(!muted) + peerConnection?.setAudioEnabled(!muted) + } + + fun handleSetMuteVideo(muted: Boolean, lockManager: LockManager) { + _videoEvents.value = VideoEnabled(!muted) + val connection = peerConnection ?: return + connection.setVideoEnabled(!muted) + dataChannel?.let { channel -> + val toSend = if (muted) VIDEO_DISABLED_JSON else VIDEO_ENABLED_JSON + val buffer = DataChannel.Buffer(ByteBuffer.wrap(toSend.toString().encodeToByteArray()), false) + channel.send(buffer) + } + + if (currentConnectionState == CallState.Connected) { + if (connection.isVideoEnabled()) lockManager.updatePhoneState(LockManager.PhoneState.IN_VIDEO) + else lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) + } + + if (localCameraState.enabled + && !signalAudioManager.isSpeakerphoneOn() + && !signalAudioManager.isBluetoothScoOn() + && !signalAudioManager.isWiredHeadsetOn() + ) { + signalAudioManager.handleCommand(AudioManagerCommand.SetUserDevice(AudioDevice.SPEAKER_PHONE)) + } + } + + fun handleSetCameraFlip() { + if (!localCameraState.enabled) return + peerConnection?.let { connection -> + connection.flipCamera() + localCameraState = connection.getCameraState() + localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + } + } + + fun setDeviceRotation(newRotation: Int) { + peerConnection?.setDeviceRotation(newRotation) + remoteRotationSink?.rotation = newRotation + } + + fun handleWiredHeadsetChanged(present: Boolean) { + if (currentConnectionState in arrayOf(CallState.Connected, + CallState.LocalRing, + CallState.RemoteRing)) { + if (present && signalAudioManager.isSpeakerphoneOn()) { + signalAudioManager.handleCommand(AudioManagerCommand.SetUserDevice(AudioDevice.WIRED_HEADSET)) + } else if (!present && !signalAudioManager.isSpeakerphoneOn() && !signalAudioManager.isBluetoothScoOn() && localCameraState.enabled) { + signalAudioManager.handleCommand(AudioManagerCommand.SetUserDevice(AudioDevice.SPEAKER_PHONE)) + } + } + } + + fun handleScreenOffChange() { + if (currentConnectionState in arrayOf(CallState.Connecting, CallState.LocalRing)) { + signalAudioManager.handleCommand(AudioManagerCommand.SilenceIncomingRinger) + } + } + + fun handleResponseMessage(recipient: Recipient, callId: UUID, answer: SessionDescription) { + if (recipient != this.recipient || callId != this.callId) { + Log.w(TAG,"Got answer for recipient and call ID we're not currently dialing") + return + } + + stateProcessor.processEvent(Event.ReceiveAnswer) { + val connection = peerConnection ?: throw AssertionError("assert") + + connection.setRemoteDescription(answer) + while (pendingIncomingIceUpdates.isNotEmpty()) { + connection.addIceCandidate(pendingIncomingIceUpdates.pop()) + } + queueOutgoingIce(callId, recipient) + } + } + + fun handleRemoteIceCandidate(iceCandidates: List, callId: UUID) { + if (callId != this.callId) { + Log.w(TAG, "Got remote ice candidates for a call that isn't active") + return + } + + val connection = peerConnection + if (connection != null && connection.readyForIce && currentConnectionState != CallState.Reconnecting) { + Log.i("Loki", "Handling connection ice candidate") + iceCandidates.forEach { candidate -> + connection.addIceCandidate(candidate) + } + } else { + Log.i("Loki", "Handling add to pending ice candidate") + pendingIncomingIceUpdates.addAll(iceCandidates) + } + } + + fun startIncomingRinger() { + signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger(true)) + } + + fun startCommunication(lockManager: LockManager) { + signalAudioManager.handleCommand(AudioManagerCommand.Start) + val connection = peerConnection ?: return + if (connection.isVideoEnabled()) lockManager.updatePhoneState(LockManager.PhoneState.IN_VIDEO) + else lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) + connection.setCommunicationMode() + setAudioEnabled(true) + dataChannel?.let { channel -> + val toSend = if (!_videoEvents.value.isEnabled) VIDEO_DISABLED_JSON else VIDEO_ENABLED_JSON + val buffer = DataChannel.Buffer(ByteBuffer.wrap(toSend.toString().encodeToByteArray()), false) + channel.send(buffer) + } + } + + fun handleAudioCommand(audioCommand: AudioManagerCommand) { + signalAudioManager.handleCommand(audioCommand) + } + + fun networkReestablished() { + val connection = peerConnection ?: return + val callId = callId ?: return + val recipient = recipient ?: return + + postConnectionEvent(Event.NetworkReconnect) { + Log.d("Loki", "start re-establish") + + val offer = connection.createOffer(MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) + }) + connection.setLocalDescription(offer) + + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) + } + } + + fun isInitiator(): Boolean = peerConnection?.isInitiator() == true + + interface WebRtcListener: PeerConnection.Observer { + fun onHangup() + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt new file mode 100644 index 000000000..a66dd591f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.webrtc + +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.utilities.WebRtcUtils +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.END_CALL +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER +import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.service.WebRtcCallService +import org.thoughtcrime.securesms.util.CallNotificationBuilder +import org.webrtc.IceCandidate + + +class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) { + + init { + lifecycle.coroutineScope.launch(IO) { + while (isActive) { + val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive() + Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED") + val sender = nextMessage.sender ?: continue + val approvedContact = Recipient.from(context, Address.fromSerialized(sender), false).isApproved + Log.i("Loki", "Contact is approved?: $approvedContact") + if (!approvedContact && storage.getUserPublicKey() != sender) continue + + if (!textSecurePreferences.isCallNotificationsEnabled()) { + Log.d("Loki","Dropping call message if call notifications disabled") + if (nextMessage.type != PRE_OFFER) continue + val sentTimestamp = nextMessage.sentTimestamp ?: continue + if (textSecurePreferences.setShownCallNotification()) { + // first time call notification encountered + val notification = CallNotificationBuilder.getFirstCallNotification(context) + context.getSystemService(NotificationManager::class.java).notify(CallNotificationBuilder.WEBRTC_NOTIFICATION, notification) + insertMissedCall(sender, sentTimestamp, isFirstCall = true) + } else { + insertMissedCall(sender, sentTimestamp) + } + continue + } + when (nextMessage.type) { + OFFER -> incomingCall(nextMessage) + ANSWER -> incomingAnswer(nextMessage) + END_CALL -> incomingHangup(nextMessage) + ICE_CANDIDATES -> handleIceCandidates(nextMessage) + PRE_OFFER -> incomingPreOffer(nextMessage) + PROVISIONAL_ANSWER, null -> {} // TODO: if necessary + } + } + } + } + + private fun insertMissedCall(sender: String, sentTimestamp: Long, isFirstCall: Boolean = false) { + val currentUserPublicKey = storage.getUserPublicKey() + if (sender == currentUserPublicKey) return // don't insert a "missed" due to call notifications disabled if it's our own sender + if (isFirstCall) { + storage.insertCallMessage(sender, CallMessageType.CALL_FIRST_MISSED, sentTimestamp) + } else { + storage.insertCallMessage(sender, CallMessageType.CALL_MISSED, sentTimestamp) + } + } + + private fun incomingHangup(callMessage: CallMessage) { + val callId = callMessage.callId ?: return + val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId) + context.startService(hangupIntent) + } + + private fun incomingAnswer(callMessage: CallMessage) { + val recipientAddress = callMessage.sender ?: return + val callId = callMessage.callId ?: return + val sdp = callMessage.sdps.firstOrNull() ?: return + val answerIntent = WebRtcCallService.incomingAnswer( + context = context, + address = Address.fromSerialized(recipientAddress), + sdp = sdp, + callId = callId + ) + context.startService(answerIntent) + } + + private fun handleIceCandidates(callMessage: CallMessage) { + val callId = callMessage.callId ?: return + val sender = callMessage.sender ?: return + + val iceCandidates = callMessage.iceCandidates() + if (iceCandidates.isEmpty()) return + + val iceIntent = WebRtcCallService.iceCandidates( + context = context, + iceCandidates = iceCandidates, + callId = callId, + address = Address.fromSerialized(sender) + ) + context.startService(iceIntent) + } + + private fun incomingPreOffer(callMessage: CallMessage) { + // handle notification state + val recipientAddress = callMessage.sender ?: return + val callId = callMessage.callId ?: return + val incomingIntent = WebRtcCallService.preOffer( + context = context, + address = Address.fromSerialized(recipientAddress), + callId = callId, + callTime = callMessage.sentTimestamp!! + ) + ContextCompat.startForegroundService(context, incomingIntent) + } + + private fun incomingCall(callMessage: CallMessage) { + val recipientAddress = callMessage.sender ?: return + val callId = callMessage.callId ?: return + val sdp = callMessage.sdps.firstOrNull() ?: return + val incomingIntent = WebRtcCallService.incomingCall( + context = context, + address = Address.fromSerialized(recipientAddress), + sdp = sdp, + callId = callId, + callTime = callMessage.sentTimestamp!! + ) + ContextCompat.startForegroundService(context, incomingIntent) + + } + + private fun CallMessage.iceCandidates(): List { + if (sdpMids.size != sdpMLineIndexes.size || sdpMLineIndexes.size != sdps.size) { + return listOf() // uneven sdp numbers + } + val candidateSize = sdpMids.size + return (0 until candidateSize).map { i -> + IceCandidate(sdpMids[i], sdpMLineIndexes[i], sdps[i]) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt new file mode 100644 index 000000000..4f27e5d1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.webrtc + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager +import org.webrtc.SurfaceViewRenderer +import javax.inject.Inject + +@HiltViewModel +class CallViewModel @Inject constructor(private val callManager: CallManager): ViewModel() { + + enum class State { + CALL_PENDING, + + CALL_PRE_INIT, + CALL_INCOMING, + CALL_OUTGOING, + CALL_CONNECTED, + CALL_RINGING, + CALL_BUSY, + CALL_DISCONNECTED, + CALL_RECONNECTING, + + NETWORK_FAILURE, + RECIPIENT_UNAVAILABLE, + NO_SUCH_USER, + UNTRUSTED_IDENTITY, + } + + val localRenderer: SurfaceViewRenderer? + get() = callManager.localRenderer + + val remoteRenderer: SurfaceViewRenderer? + get() = callManager.remoteRenderer + + private var _videoEnabled: Boolean = false + + val videoEnabled: Boolean + get() = _videoEnabled + + private var _microphoneEnabled: Boolean = true + + val microphoneEnabled: Boolean + get() = _microphoneEnabled + + private var _isSpeaker: Boolean = false + val isSpeaker: Boolean + get() = _isSpeaker + + val audioDeviceState + get() = callManager.audioDeviceEvents + .onEach { + _isSpeaker = it.selectedDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE + } + + val localAudioEnabledState + get() = callManager.audioEvents.map { it.isEnabled } + .onEach { _microphoneEnabled = it } + + val localVideoEnabledState + get() = callManager.videoEvents + .map { it.isEnabled } + .onEach { _videoEnabled = it } + + val remoteVideoEnabledState + get() = callManager.remoteVideoEvents.map { it.isEnabled } + + var deviceRotation: Int = 0 + set(value) { + field = value + callManager.setDeviceRotation(value) + } + + val currentCallState + get() = callManager.currentCallState + + val callState + get() = callManager.callStateEvents + + val recipient + get() = callManager.recipientEvents + + val callStartTime: Long + get() = callManager.callStartTime + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java new file mode 100644 index 000000000..01b161e08 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.ResultReceiver; +import android.telephony.TelephonyManager; + +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.service.WebRtcCallService; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Listens for incoming PSTN calls and rejects them if a RedPhone call is already in progress. + * + * Unstable use of reflection employed to gain access to ITelephony. + * + */ +public class IncomingPstnCallReceiver extends BroadcastReceiver { + + private static final String TAG = IncomingPstnCallReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Checking incoming call..."); + + if (intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) == null) { + Log.w(TAG, "Telephony event does not contain number..."); + return; + } + + if (!intent.getStringExtra(TelephonyManager.EXTRA_STATE).equals(TelephonyManager.EXTRA_STATE_RINGING)) { + Log.w(TAG, "Telephony event is not state ringing..."); + return; + } + + InCallListener listener = new InCallListener(context, new Handler()); + + WebRtcCallService.isCallActive(context, listener); + } + + private static class InCallListener extends ResultReceiver { + + private final Context context; + + InCallListener(Context context, Handler handler) { + super(handler); + this.context = context.getApplicationContext(); + } + + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (resultCode == 1) { + Log.i(TAG, "Attempting to deny incoming PSTN call."); + + TelephonyManager tm = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); + + try { + Method getTelephony = tm.getClass().getDeclaredMethod("getITelephony"); + getTelephony.setAccessible(true); + Object telephonyService = getTelephony.invoke(tm); + Method endCall = telephonyService.getClass().getDeclaredMethod("endCall"); + endCall.invoke(telephonyService); + Log.i(TAG, "Denied Incoming Call."); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + Log.w(TAG, "Unable to access ITelephony API", e); + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt new file mode 100644 index 000000000..52ee5583d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.webrtc + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import org.session.libsignal.utilities.Log + +class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) { + + private val networkList: MutableSet = mutableSetOf() + + val broadcastDelegate = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + receiveBroadcast(context, intent) + } + } + + val defaultObserver = object: ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + Log.i("Loki", "onAvailable: $network") + networkList += network + onNetworkChangedCallback(networkList.isNotEmpty()) + } + + override fun onLosing(network: Network, maxMsToLive: Int) { + Log.i("Loki", "onLosing: $network, maxMsToLive: $maxMsToLive") + } + + override fun onLost(network: Network) { + Log.i("Loki", "onLost: $network") + networkList -= network + onNetworkChangedCallback(networkList.isNotEmpty()) + } + + override fun onUnavailable() { + Log.i("Loki", "onUnavailable") + } + } + + fun receiveBroadcast(context: Context, intent: Intent) { + val connected = context.isConnected() + Log.i("Loki", "received broadcast, network connected: $connected") + onNetworkChangedCallback(connected) + } + + fun Context.isConnected() : Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return cm.activeNetwork != null + } + + fun register(context: Context) { + val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE") + context.registerReceiver(broadcastDelegate, intentFilter) +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { +// val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager +// cm.registerDefaultNetworkCallback(defaultObserver) +// } else { +// +// } + } + + fun unregister(context: Context) { + context.unregisterReceiver(broadcastDelegate) +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { +// val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager +// cm.unregisterNetworkCallback(defaultObserver) +// } else { +// +// } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionException.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionException.kt new file mode 100644 index 000000000..2e8d79d62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionException.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.webrtc + +class PeerConnectionException: Exception { + constructor(error: String?): super(error) + constructor(throwable: Throwable): super(throwable) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt new file mode 100644 index 000000000..26d8fc223 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -0,0 +1,335 @@ +package org.thoughtcrime.securesms.webrtc + +import android.content.Context +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SettableFuture +import org.thoughtcrime.securesms.webrtc.video.Camera +import org.thoughtcrime.securesms.webrtc.video.CameraEventListener +import org.thoughtcrime.securesms.webrtc.video.CameraState +import org.thoughtcrime.securesms.webrtc.video.RotationVideoSink +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.VideoSink +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import java.security.SecureRandom +import java.util.concurrent.ExecutionException +import kotlin.random.asKotlinRandom + +class PeerConnectionWrapper(private val context: Context, + private val factory: PeerConnectionFactory, + private val observer: PeerConnection.Observer, + private val localRenderer: VideoSink, + private val cameraEventListener: CameraEventListener, + private val eglBase: EglBase, + private val relay: Boolean = false): CameraEventListener { + + private var peerConnection: PeerConnection? = null + private val audioTrack: AudioTrack + private val audioSource: AudioSource + private val camera: Camera + private val mediaStream: MediaStream + private val videoSource: VideoSource? + private val videoTrack: VideoTrack? + private val rotationVideoSink = RotationVideoSink() + + val readyForIce + get() = peerConnection?.localDescription != null && peerConnection?.remoteDescription != null + + private var isInitiator = false + + private fun initPeerConnection() { + val random = SecureRandom().asKotlinRandom() + val iceServers = listOf("freyr","fenrir","frigg","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> + PeerConnection.IceServer.builder("turn:$sub.getsession.org") + .setUsername("session202111") + .setPassword("053c268164bc7bd7") + .createIceServer() + } + + val constraints = MediaConstraints().apply { + optional.add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")) + } + + val configuration = PeerConnection.RTCConfiguration(iceServers).apply { + bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE + if (relay) { + iceTransportsType = PeerConnection.IceTransportsType.RELAY + } + } + + val newPeerConnection = factory.createPeerConnection(configuration, constraints, observer)!! + peerConnection = newPeerConnection + newPeerConnection.setAudioPlayout(true) + newPeerConnection.setAudioRecording(true) + + newPeerConnection.addStream(mediaStream) + } + + init { + val audioConstraints = MediaConstraints().apply { + optional.add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")) + } + + mediaStream = factory.createLocalMediaStream("ARDAMS") + audioSource = factory.createAudioSource(audioConstraints) + audioTrack = factory.createAudioTrack("ARDAMSa0", audioSource) + audioTrack.setEnabled(true) + mediaStream.addTrack(audioTrack) + + val newCamera = Camera(context, this) + camera = newCamera + + if (newCamera.capturer != null) { + val newVideoSource = factory.createVideoSource(false) + videoSource = newVideoSource + val newVideoTrack = factory.createVideoTrack("ARDAMSv0", newVideoSource) + videoTrack = newVideoTrack + + rotationVideoSink.setObserver(newVideoSource.capturerObserver) + newCamera.capturer.initialize( + SurfaceTextureHelper.create("WebRTC-SurfaceTextureHelper", eglBase.eglBaseContext), + context, + rotationVideoSink + ) + rotationVideoSink.mirrored = newCamera.activeDirection == CameraState.Direction.FRONT + rotationVideoSink.setSink(localRenderer) + newVideoTrack.setEnabled(false) + mediaStream.addTrack(newVideoTrack) + } else { + videoSource = null + videoTrack = null + } + initPeerConnection() + } + + fun getCameraState(): CameraState { + return CameraState(camera.activeDirection, camera.cameraCount) + } + + fun createDataChannel(channelName: String): DataChannel { + val dataChannelConfiguration = DataChannel.Init().apply { + ordered = true + negotiated = true + id = 548 + } + return peerConnection!!.createDataChannel(channelName, dataChannelConfiguration) + } + + fun addIceCandidate(candidate: IceCandidate) { + // TODO: filter logic based on known servers + peerConnection!!.addIceCandidate(candidate) + } + + fun dispose() { + camera.dispose() + + videoSource?.dispose() + + audioSource.dispose() + peerConnection?.close() + peerConnection?.dispose() + } + + fun setNewRemoteDescription(description: SessionDescription) { + val future = SettableFuture() + + peerConnection!!.setRemoteDescription(object: SdpObserver { + override fun onCreateSuccess(p0: SessionDescription?) { + throw AssertionError() + } + + override fun onCreateFailure(p0: String?) { + throw AssertionError() + } + + override fun onSetSuccess() { + future.set(true) + } + + override fun onSetFailure(error: String?) { + future.setException(PeerConnectionException(error)) + } + }, description) + + try { + future.get() + } catch (e: InterruptedException) { + throw AssertionError(e) + } catch (e: ExecutionException) { + throw PeerConnectionException(e) + } + } + + fun setRemoteDescription(description: SessionDescription) { + val future = SettableFuture() + + peerConnection!!.setRemoteDescription(object: SdpObserver { + override fun onCreateSuccess(p0: SessionDescription?) { + throw AssertionError() + } + + override fun onCreateFailure(p0: String?) { + throw AssertionError() + } + + override fun onSetSuccess() { + future.set(true) + } + + override fun onSetFailure(error: String?) { + future.setException(PeerConnectionException(error)) + } + }, description) + + try { + future.get() + } catch (e: InterruptedException) { + throw AssertionError(e) + } catch (e: ExecutionException) { + throw PeerConnectionException(e) + } + } + + fun createAnswer(mediaConstraints: MediaConstraints) : SessionDescription { + val future = SettableFuture() + + peerConnection!!.createAnswer(object:SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + future.set(sdp) + } + + override fun onSetSuccess() { + throw AssertionError() + } + + override fun onCreateFailure(p0: String?) { + future.setException(PeerConnectionException(p0)) + } + + override fun onSetFailure(p0: String?) { + throw AssertionError() + } + }, mediaConstraints) + + try { + return correctSessionDescription(future.get()) + } catch (e: InterruptedException) { + throw AssertionError() + } catch (e: ExecutionException) { + throw PeerConnectionException(e) + } + } + + private fun correctSessionDescription(sessionDescription: SessionDescription): SessionDescription { + val updatedSdp = sessionDescription.description.replace("(a=fmtp:111 ((?!cbr=).)*)\r?\n".toRegex(), "$1;cbr=1\r\n") + .replace(".+urn:ietf:params:rtp-hdrext:ssrc-audio-level.*\r?\n".toRegex(), "") + + return SessionDescription(sessionDescription.type, updatedSdp) + } + + fun createOffer(mediaConstraints: MediaConstraints): SessionDescription { + val future = SettableFuture() + + peerConnection!!.createOffer(object:SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + future.set(sdp) + } + + override fun onSetSuccess() { + throw AssertionError() + } + + override fun onCreateFailure(p0: String?) { + future.setException(PeerConnectionException(p0)) + } + + override fun onSetFailure(p0: String?) { + throw AssertionError() + } + }, mediaConstraints) + + try { + isInitiator = true + return correctSessionDescription(future.get()) + } catch (e: InterruptedException) { + throw AssertionError() + } catch (e: ExecutionException) { + throw PeerConnectionException(e) + } + } + + fun setLocalDescription(sdp: SessionDescription) { + val future = SettableFuture() + + peerConnection!!.setLocalDescription(object: SdpObserver { + override fun onCreateSuccess(p0: SessionDescription?) { + + } + + override fun onSetSuccess() { + future.set(true) + } + + override fun onCreateFailure(p0: String?) {} + + override fun onSetFailure(error: String?) { + future.setException(PeerConnectionException(error)) + } + }, sdp) + + try { + future.get() + } catch(e: InterruptedException) { + throw AssertionError(e) + } catch(e: ExecutionException) { + throw PeerConnectionException(e) + } + } + + fun setCommunicationMode() { + peerConnection?.setAudioPlayout(true) + peerConnection?.setAudioRecording(true) + } + + fun setAudioEnabled(isEnabled: Boolean) { + audioTrack.setEnabled(isEnabled) + } + + fun setDeviceRotation(rotation: Int) { + Log.d("Loki", "rotation: $rotation") + rotationVideoSink.rotation = rotation + } + + fun setVideoEnabled(isEnabled: Boolean) { + videoTrack?.let { track -> + track.setEnabled(isEnabled) + camera.enabled = isEnabled + } + } + + fun isVideoEnabled() = camera.enabled + + fun flipCamera() { + camera.flip() + } + + override fun onCameraSwitchCompleted(newCameraState: CameraState) { + // mirror rotation offset + rotationVideoSink.mirrored = newCameraState.activeDirection == CameraState.Direction.FRONT + cameraEventListener.onCameraSwitchCompleted(newCameraState) + } + + fun isInitiator(): Boolean = isInitiator +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PreOffer.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PreOffer.kt new file mode 100644 index 000000000..dfc2dc6fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PreOffer.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.webrtc + +import org.session.libsession.utilities.recipients.Recipient +import java.util.* + +data class PreOffer(val callId: UUID, val recipient: Recipient) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java new file mode 100644 index 000000000..56271b980 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.webrtc; + +import org.session.libsignal.utilities.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * Allows multiple default uncaught exception handlers to be registered + * + * Calls all registered handlers in reverse order of registration. + * Errors in one handler do not prevent subsequent handlers from being called. + */ +public class UncaughtExceptionHandlerManager implements Thread.UncaughtExceptionHandler { + private final Thread.UncaughtExceptionHandler originalHandler; + private final List handlers = new ArrayList(); + + public UncaughtExceptionHandlerManager() { + originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + registerHandler(originalHandler); + Thread.setDefaultUncaughtExceptionHandler(this); + } + + public void registerHandler(Thread.UncaughtExceptionHandler handler) { + handlers.add(handler); + } + + public void unregister() { + Thread.setDefaultUncaughtExceptionHandler(originalHandler); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + for (int i = handlers.size() - 1; i >= 0; i--) { + try { + handlers.get(i).uncaughtException(thread, throwable); + } catch(Throwable t) { + Log.e("UncaughtExceptionHandlerManager", "Error in uncaught exception handling", t); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt new file mode 100644 index 000000000..955356c7d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.webrtc + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.telephony.PhoneStateListener +import android.telephony.TelephonyManager +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.service.WebRtcCallService +import org.thoughtcrime.securesms.webrtc.locks.LockManager + + +class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): PhoneStateListener() { + + companion object { + private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java) + } + + override fun onCallStateChanged(state: Int, phoneNumber: String?) { + super.onCallStateChanged(state, phoneNumber) + if (state == TelephonyManager.CALL_STATE_OFFHOOK) { + hangupListener() + Log.i(TAG, "Device phone call ended Session call.") + } + } +} + +class PowerButtonReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (Intent.ACTION_SCREEN_OFF == intent.action) { + val serviceIntent = Intent(context,WebRtcCallService::class.java) + .setAction(WebRtcCallService.ACTION_SCREEN_OFF) + context.startService(serviceIntent) + } + } +} + +class ProximityLockRelease(private val lockManager: LockManager): Thread.UncaughtExceptionHandler { + companion object { + private val TAG = Log.tag(ProximityLockRelease::class.java) + } + override fun uncaughtException(t: Thread, e: Throwable) { + Log.e(TAG,"Uncaught exception - releasing proximity lock", e) + lockManager.updatePhoneState(LockManager.PhoneState.IDLE) + } +} + +class WiredHeadsetStateReceiver: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getIntExtra("state", -1) + val serviceIntent = Intent(context, WebRtcCallService::class.java) + .setAction(WebRtcCallService.ACTION_WIRED_HEADSET_CHANGE) + .putExtra(WebRtcCallService.EXTRA_AVAILABLE, state != 0) + + context.startService(serviceIntent) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java new file mode 100644 index 000000000..da50cd084 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java @@ -0,0 +1,223 @@ +package org.thoughtcrime.securesms.webrtc.audio; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.SoundPool; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.session.libsession.utilities.ServiceUtil; +import org.session.libsignal.utilities.Log; + + +public abstract class AudioManagerCompat { + + private static final String TAG = Log.tag(AudioManagerCompat.class); + + private static final int AUDIOFOCUS_GAIN = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE; + + protected final AudioManager audioManager; + + @SuppressWarnings("CodeBlock2Expr") + protected final AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener = focusChange -> { + Log.i(TAG, "onAudioFocusChangeListener: " + focusChange); + }; + + private AudioManagerCompat(@NonNull Context context) { + audioManager = ServiceUtil.getAudioManager(context); + } + + public boolean isBluetoothScoAvailableOffCall() { + return audioManager.isBluetoothScoAvailableOffCall(); + } + + public void startBluetoothSco() { + audioManager.startBluetoothSco(); + } + + public void stopBluetoothSco() { + audioManager.stopBluetoothSco(); + } + + public boolean isBluetoothScoOn() { + return audioManager.isBluetoothScoOn(); + } + + public void setBluetoothScoOn(boolean on) { + audioManager.setBluetoothScoOn(on); + } + + public int getMode() { + return audioManager.getMode(); + } + + public void setMode(int modeInCommunication) { + audioManager.setMode(modeInCommunication); + } + + public boolean isSpeakerphoneOn() { + return audioManager.isSpeakerphoneOn(); + } + + public void setSpeakerphoneOn(boolean on) { + audioManager.setSpeakerphoneOn(on); + } + + public boolean isMicrophoneMute() { + return audioManager.isMicrophoneMute(); + } + + public void setMicrophoneMute(boolean on) { + audioManager.setMicrophoneMute(on); + } + + public boolean hasEarpiece(@NonNull Context context) { + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + } + + @SuppressLint("WrongConstant") + public boolean isWiredHeadsetOn() { + AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); + for (AudioDeviceInfo device : devices) { + final int type = device.getType(); + if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { + return true; + } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) { + return true; + } + } + return false; + } + + public float ringVolumeWithMinimum() { + int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING); + int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING); + float volume = logVolume(currentVolume, maxVolume); + float minVolume = logVolume(15, 100); + return Math.max(volume, minVolume); + } + + private static float logVolume(int volume, int maxVolume) { + if (maxVolume == 0 || volume > maxVolume) { + return 0.5f; + } + return (float) (1 - (Math.log(maxVolume + 1 - volume) / Math.log(maxVolume + 1))); + } + + abstract public SoundPool createSoundPool(); + abstract public void requestCallAudioFocus(); + abstract public void abandonCallAudioFocus(); + + public static AudioManagerCompat create(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 26) { + return new Api26AudioManagerCompat(context); + } else { + return new Api21AudioManagerCompat(context); + } + } + + @RequiresApi(26) + private static class Api26AudioManagerCompat extends AudioManagerCompat { + + private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + + private AudioFocusRequest audioFocusRequest; + + private Api26AudioManagerCompat(@NonNull Context context) { + super(context); + } + + @Override + public SoundPool createSoundPool() { + return new SoundPool.Builder() + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setMaxStreams(1) + .build(); + } + + @Override + public void requestCallAudioFocus() { + if (audioFocusRequest != null) { + Log.w(TAG, "Already requested audio focus. Ignoring..."); + return; + } + + audioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN) + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setOnAudioFocusChangeListener(onAudioFocusChangeListener) + .build(); + + int result = audioManager.requestAudioFocus(audioFocusRequest); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted. Result code: " + result); + } + } + + @Override + public void abandonCallAudioFocus() { + if (audioFocusRequest == null) { + Log.w(TAG, "Don't currently have audio focus. Ignoring..."); + return; + } + + int result = audioManager.abandonAudioFocusRequest(audioFocusRequest); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus abandon failed. Result code: " + result); + } + + audioFocusRequest = null; + } + } + + @RequiresApi(21) + private static class Api21AudioManagerCompat extends AudioManagerCompat { + + private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL) + .build(); + + private Api21AudioManagerCompat(@NonNull Context context) { + super(context); + } + + @Override + public SoundPool createSoundPool() { + return new SoundPool.Builder() + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setMaxStreams(1) + .build(); + } + + @Override + public void requestCallAudioFocus() { + int result = audioManager.requestAudioFocus(onAudioFocusChangeListener, AudioManager.STREAM_VOICE_CALL, AUDIOFOCUS_GAIN); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted. Result code: " + result); + } + } + + @Override + public void abandonCallAudioFocus() { + int result = audioManager.abandonAudioFocus(onAudioFocusChangeListener); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus abandon failed. Result code: " + result); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt new file mode 100644 index 000000000..1b3995a2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.RingtoneManager +import android.os.Vibrator +import org.session.libsession.utilities.ServiceUtil +import org.session.libsignal.utilities.Log + +class IncomingRinger(private val context: Context) { + companion object { + const val TAG = "IncomingRinger" + val PATTERN = longArrayOf(0L, 1000L, 1000L) + } + + private val vibrator: Vibrator? = ServiceUtil.getVibrator(context) + var mediaPlayer: MediaPlayer? = null + + val isRinging: Boolean + get() = mediaPlayer?.isPlaying ?: false + + fun start(vibrate: Boolean) { + val audioManager = ServiceUtil.getAudioManager(context) + mediaPlayer?.release() + mediaPlayer = createMediaPlayer() + val ringerMode = audioManager.ringerMode + + if (shouldVibrate(mediaPlayer, ringerMode, vibrate)) { + Log.i(TAG,"Starting vibration") + vibrator?.vibrate(PATTERN, 1) + } else { + Log.i(TAG,"Skipping vibration") + } + + mediaPlayer?.let { player -> + if (ringerMode == AudioManager.RINGER_MODE_NORMAL) { + try { + if (!player.isPlaying) { + player.prepare() + player.start() + Log.i(TAG,"Playing ringtone") + } + } catch (e: Exception) { + Log.e(TAG,"Failed to start mediaPlayer", e) + } + } + } ?: run { + Log.w(TAG,"Not ringing, mediaPlayer: ${mediaPlayer?.let{"available"}}, mode: $ringerMode") + } + + } + + fun stop() { + mediaPlayer?.release() + mediaPlayer = null + vibrator?.cancel() + } + + private fun shouldVibrate(player: MediaPlayer?, ringerMode: Int, vibrate: Boolean): Boolean { + player ?: return true + + if (vibrator == null || !vibrator.hasVibrator()) return false + + return if (vibrate) ringerMode != AudioManager.RINGER_MODE_SILENT + else ringerMode == AudioManager.RINGER_MODE_VIBRATE + } + + private fun createMediaPlayer(): MediaPlayer? { + try { + val defaultRingtone = try { + RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) + } catch (e: Exception) { + Log.e(TAG, "Failed to get default system ringtone", e) + null + } ?: return null + + try { + val mediaPlayer = MediaPlayer() + mediaPlayer.setDataSource(context, defaultRingtone) + return mediaPlayer + } catch (e: SecurityException) { + Log.w(TAG, "Failed to create player with ringtone the normal way", e) + } + } catch (e: Exception) { + Log.e(TAG,"Failed to create mediaPlayer") + } + + return null + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt new file mode 100644 index 000000000..465866931 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.net.Uri +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import java.io.IOException +import java.lang.IllegalArgumentException +import java.lang.IllegalStateException + +class OutgoingRinger(private val context: Context) { + enum class Type { + RINGING, BUSY + } + + private var mediaPlayer: MediaPlayer? = null + fun start(type: Type) { + val soundId: Int = if (type == Type.RINGING) R.raw.redphone_outring else if (type == Type.BUSY) R.raw.redphone_busy else throw IllegalArgumentException("Not a valid sound type") + if (mediaPlayer != null) { + mediaPlayer!!.release() + } + mediaPlayer = MediaPlayer() + mediaPlayer!!.setAudioAttributes(AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build()) + mediaPlayer!!.isLooping = true + val packageName = context.packageName + val dataUri = Uri.parse("android.resource://$packageName/$soundId") + try { + mediaPlayer!!.setDataSource(context, dataUri) + mediaPlayer!!.prepare() + mediaPlayer!!.start() + } catch (e: IllegalArgumentException) { + Log.e(TAG, e) + } catch (e: SecurityException) { + Log.e(TAG, e) + } catch (e: IllegalStateException) { + Log.e(TAG, e) + } catch (e: IOException) { + Log.e(TAG, e) + } + } + + fun stop() { + if (mediaPlayer == null) return + mediaPlayer!!.release() + mediaPlayer = null + } + + companion object { + private val TAG: String = Log.tag(OutgoingRinger::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt new file mode 100644 index 000000000..89eba2a3a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.os.Handler +import android.os.Looper + +/** + * Handler to run all audio/bluetooth operations. Provides current thread + * assertion for enforcing use of the handler when necessary. + */ +class SignalAudioHandler(looper: Looper) : Handler(looper) { + + fun assertHandlerThread() { + if (!isOnHandler()) { + throw AssertionError("Must run on audio handler thread.") + } + } + + fun isOnHandler(): Boolean { + return Looper.myLooper() == looper + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt new file mode 100644 index 000000000..2b4d34807 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -0,0 +1,393 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.media.SoundPool +import android.os.Build +import android.os.HandlerThread +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.AudioManagerCommand +import org.thoughtcrime.securesms.webrtc.audio.SignalBluetoothManager.State as BState + +private val TAG = Log.tag(SignalAudioManager::class.java) + +/** + * Manage all audio and bluetooth routing for calling. Primarily, operates by maintaining a list + * of available devices (wired, speaker, bluetooth, earpiece) and then using a state machine to determine + * which device to use. Inputs into the decision include the [defaultAudioDevice] (set based on if audio + * only or video call) and [userSelectedAudioDevice] (set by user interaction with UI). [autoSwitchToWiredHeadset] + * and [autoSwitchToBluetooth] also impact the decision by forcing the user selection to the respective device + * when initially discovered. If the user switches to another device while bluetooth or wired headset are + * connected, the system will not auto switch back until the audio device is disconnected and reconnected. + * + * For example, call starts with speaker, then a bluetooth headset is connected. The audio will automatically + * switch to the headset. The user can then switch back to speaker through a manual interaction. If the + * bluetooth headset is then disconnected, and reconnected, the audio will again automatically switch to + * the bluetooth headset. + */ +class SignalAudioManager(private val context: Context, + private val eventListener: EventListener?, + private val androidAudioManager: AudioManagerCompat) { + + private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio").apply { start() } + private var handler: SignalAudioHandler? = null + + private var signalBluetoothManager: SignalBluetoothManager? = null + + private var state: State = State.UNINITIALIZED + + private var savedAudioMode = AudioManager.MODE_INVALID + private var savedIsSpeakerPhoneOn = false + private var savedIsMicrophoneMute = false + private var hasWiredHeadset = false + private var autoSwitchToWiredHeadset = true + private var autoSwitchToBluetooth = true + + private var defaultAudioDevice: AudioDevice = AudioDevice.EARPIECE + private var selectedAudioDevice: AudioDevice = AudioDevice.NONE + private var userSelectedAudioDevice: AudioDevice = AudioDevice.NONE + + private var audioDevices: MutableSet = mutableSetOf() + + private val soundPool: SoundPool = androidAudioManager.createSoundPool() + private val connectedSoundId = soundPool.load(context, R.raw.webrtc_completed, 1) + private val disconnectedSoundId = soundPool.load(context, R.raw.webrtc_disconnected, 1) + + private val incomingRinger = IncomingRinger(context) + private val outgoingRinger = OutgoingRinger(context) + + private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null + + fun handleCommand(command: AudioManagerCommand) { + if (command == AudioManagerCommand.Initialize) { + initialize() + return + } + handler?.post { + when (command) { + is AudioManagerCommand.UpdateAudioDeviceState -> updateAudioDeviceState() + is AudioManagerCommand.Start -> start() + is AudioManagerCommand.Stop -> stop(command.playDisconnect) + is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.device, command.clearUserEarpieceSelection) + is AudioManagerCommand.SetUserDevice -> selectAudioDevice(command.device) + is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger(command.vibrate) + is AudioManagerCommand.SilenceIncomingRinger -> silenceIncomingRinger() + is AudioManagerCommand.StartOutgoingRinger -> startOutgoingRinger(command.type) + } + } + } + + private fun initialize() { + Log.i(TAG, "Initializing audio manager state: $state") + + if (state == State.UNINITIALIZED) { + commandAndControlThread = HandlerThread("call-audio").apply { start() } + handler = SignalAudioHandler(commandAndControlThread!!.looper) + + signalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, handler!!) + + handler!!.post { + + savedAudioMode = androidAudioManager.mode + savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn + savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute + hasWiredHeadset = androidAudioManager.isWiredHeadsetOn + + androidAudioManager.requestCallAudioFocus() + + setMicrophoneMute(false) + + audioDevices.clear() + + signalBluetoothManager!!.start() + + updateAudioDeviceState() + + wiredHeadsetReceiver = WiredHeadsetReceiver() + context.registerReceiver(wiredHeadsetReceiver, IntentFilter(if (Build.VERSION.SDK_INT >= 21) AudioManager.ACTION_HEADSET_PLUG else Intent.ACTION_HEADSET_PLUG)) + + state = State.PREINITIALIZED + + Log.d(TAG, "Initialized") + } + } + } + + private fun start() { + Log.d(TAG, "Starting. state: $state") + if (state == State.RUNNING) { + Log.w(TAG, "Skipping, already active") + return + } + + incomingRinger.stop() + outgoingRinger.stop() + + state = State.RUNNING + + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + + val volume: Float = androidAudioManager.ringVolumeWithMinimum() + soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f) + + Log.d(TAG, "Started") + } + + private fun stop(playDisconnect: Boolean) { + Log.d(TAG, "Stopping. state: $state") + if (state == State.UNINITIALIZED) { + Log.i(TAG, "Trying to stop AudioManager in incorrect state: $state") + return + } + + handler?.post { + incomingRinger.stop() + outgoingRinger.stop() + stop(false) + if (commandAndControlThread != null) { + Log.i(TAG, "Shutting down command and control") + commandAndControlThread?.quitSafely() + commandAndControlThread = null + } + } + + if (playDisconnect) { + val volume: Float = androidAudioManager.ringVolumeWithMinimum() + soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f) + } + + state = State.UNINITIALIZED + + wiredHeadsetReceiver?.let { receiver -> + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + Log.e(TAG, "error unregistering wiredHeadsetReceiver", e) + } + } + wiredHeadsetReceiver = null + + signalBluetoothManager?.stop() + + setSpeakerphoneOn(savedIsSpeakerPhoneOn) + setMicrophoneMute(savedIsMicrophoneMute) + androidAudioManager.mode = savedAudioMode + + androidAudioManager.abandonCallAudioFocus() + Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams") + + Log.d(TAG, "Stopped") + } + + private fun updateAudioDeviceState() { + handler!!.assertHandlerThread() + + Log.i( + TAG, + "updateAudioDeviceState(): " + + "wired: $hasWiredHeadset " + + "bt: ${signalBluetoothManager!!.state} " + + "available: $audioDevices " + + "selected: $selectedAudioDevice " + + "userSelected: $userSelectedAudioDevice" + ) + + if (signalBluetoothManager!!.state.shouldUpdate()) { + signalBluetoothManager!!.updateDevice() + } + + val newAudioDevices = mutableSetOf(AudioDevice.SPEAKER_PHONE) + + if (signalBluetoothManager!!.state.hasDevice()) { + newAudioDevices += AudioDevice.BLUETOOTH + } + + if (hasWiredHeadset) { + newAudioDevices += AudioDevice.WIRED_HEADSET + } else { + autoSwitchToWiredHeadset = true + if (androidAudioManager.hasEarpiece(context)) { + newAudioDevices += AudioDevice.EARPIECE + } + } + + var audioDeviceSetUpdated = audioDevices != newAudioDevices + audioDevices = newAudioDevices + + if (signalBluetoothManager!!.state == BState.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) { + userSelectedAudioDevice = AudioDevice.NONE + } + + if (hasWiredHeadset && autoSwitchToWiredHeadset) { + userSelectedAudioDevice = AudioDevice.WIRED_HEADSET + autoSwitchToWiredHeadset = false + } + + if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) { + userSelectedAudioDevice = AudioDevice.NONE + } + + val btState = signalBluetoothManager!!.state + val needBluetoothAudioStart = btState == BState.AVAILABLE && + (userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH || autoSwitchToBluetooth) + + val needBluetoothAudioStop = (btState == BState.CONNECTED || btState == BState.CONNECTING) && + (userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH) + + if (btState.hasDevice()) { + Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager!!.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop") + } + + if (needBluetoothAudioStop) { + signalBluetoothManager!!.stopScoAudio() + signalBluetoothManager!!.updateDevice() + } + + if (!autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.UNAVAILABLE) { + autoSwitchToBluetooth = true + } + + if (needBluetoothAudioStart && !needBluetoothAudioStop) { + if (!signalBluetoothManager!!.startScoAudio()) { + Log.e(TAG,"Failed to start sco audio") + audioDevices.remove(AudioDevice.BLUETOOTH) + audioDeviceSetUpdated = true + } + } + + if (autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.CONNECTED) { + userSelectedAudioDevice = AudioDevice.BLUETOOTH + autoSwitchToBluetooth = false + } + + val newAudioDevice: AudioDevice = when { + audioDevices.contains(userSelectedAudioDevice) -> userSelectedAudioDevice + audioDevices.contains(defaultAudioDevice) -> defaultAudioDevice + else -> AudioDevice.SPEAKER_PHONE + } + + if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) { + setAudioDevice(newAudioDevice) + Log.i(TAG, "New device status: available: $audioDevices, selected: $newAudioDevice") + eventListener?.onAudioDeviceChanged(selectedAudioDevice, audioDevices) + } + } + + private fun setDefaultAudioDevice(newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) { + Log.d(TAG, "setDefaultAudioDevice(): currentDefault: $defaultAudioDevice device: $newDefaultDevice clearUser: $clearUserEarpieceSelection") + defaultAudioDevice = when (newDefaultDevice) { + AudioDevice.SPEAKER_PHONE -> newDefaultDevice + AudioDevice.EARPIECE -> { + if (androidAudioManager.hasEarpiece(context)) { + newDefaultDevice + } else { + AudioDevice.SPEAKER_PHONE + } + } + else -> throw AssertionError("Invalid default audio device selection") + } + + if (clearUserEarpieceSelection && userSelectedAudioDevice == AudioDevice.EARPIECE) { + Log.d(TAG, "Clearing user setting of earpiece") + userSelectedAudioDevice = AudioDevice.NONE + } + + Log.d(TAG, "New default: $defaultAudioDevice userSelected: $userSelectedAudioDevice") + updateAudioDeviceState() + } + + private fun selectAudioDevice(device: AudioDevice) { + val actualDevice = if (device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.WIRED_HEADSET)) AudioDevice.WIRED_HEADSET else device + + Log.d(TAG, "selectAudioDevice(): device: $device actualDevice: $actualDevice") + if (!audioDevices.contains(actualDevice)) { + Log.w(TAG, "Can not select $actualDevice from available $audioDevices") + } + userSelectedAudioDevice = actualDevice + updateAudioDeviceState() + } + + private fun setAudioDevice(device: AudioDevice) { + Log.d(TAG, "setAudioDevice(): device: $device") + if (!audioDevices.contains(device)) return + when (device) { + AudioDevice.SPEAKER_PHONE -> setSpeakerphoneOn(true) + AudioDevice.EARPIECE -> setSpeakerphoneOn(false) + AudioDevice.WIRED_HEADSET -> setSpeakerphoneOn(false) + AudioDevice.BLUETOOTH -> setSpeakerphoneOn(false) + else -> throw AssertionError("Invalid audio device selection") + } + selectedAudioDevice = device + } + + private fun setSpeakerphoneOn(on: Boolean) { + if (androidAudioManager.isSpeakerphoneOn != on) { + androidAudioManager.isSpeakerphoneOn = on + } + } + + private fun setMicrophoneMute(on: Boolean) { + if (androidAudioManager.isMicrophoneMute != on) { + androidAudioManager.isMicrophoneMute = on + } + } + + private fun startIncomingRinger(vibrate: Boolean) { + Log.i(TAG, "startIncomingRinger(): vibrate: $vibrate") + androidAudioManager.mode = AudioManager.MODE_RINGTONE + + incomingRinger.start(vibrate) + } + + private fun silenceIncomingRinger() { + Log.i(TAG, "silenceIncomingRinger():") + incomingRinger.stop() + } + + private fun startOutgoingRinger(type: OutgoingRinger.Type) { + Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice") + + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + setMicrophoneMute(false) + + outgoingRinger.start(type) + } + + private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) { + Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic") + hasWiredHeadset = pluggedIn + updateAudioDeviceState() + } + + fun isSpeakerphoneOn(): Boolean = androidAudioManager.isSpeakerphoneOn + + fun isBluetoothScoOn(): Boolean = androidAudioManager.isBluetoothScoOn + + fun isWiredHeadsetOn(): Boolean = androidAudioManager.isWiredHeadsetOn + + private inner class WiredHeadsetReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pluggedIn = intent.getIntExtra("state", 0) == 1 + val hasMic = intent.getIntExtra("microphone", 0) == 1 + + handler?.post { onWiredHeadsetChange(pluggedIn, hasMic) } + } + } + + enum class AudioDevice { + SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE + } + + enum class State { + UNINITIALIZED, PREINITIALIZED, RUNNING + } + + interface EventListener { + @JvmSuppressWildcards + fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt new file mode 100644 index 000000000..84a36ee82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt @@ -0,0 +1,364 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.AudioManagerCommand +import java.util.concurrent.TimeUnit + +/** + * Manages the bluetooth lifecycle with a headset. This class doesn't make any + * determination on if bluetooth should be used. It determines if a device is connected, + * reports that to the [SignalAudioManager], and then handles connecting/disconnecting + * to the device if requested by [SignalAudioManager]. + */ +class SignalBluetoothManager( + private val context: Context, + private val audioManager: SignalAudioManager, + private val androidAudioManager: AudioManagerCompat, + private val handler: SignalAudioHandler +) { + + var state: State = State.UNINITIALIZED + get() { + handler.assertHandlerThread() + return field + } + private set + + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothHeadset: BluetoothHeadset? = null + private var scoConnectionAttempts = 0 + + private val bluetoothListener = BluetoothServiceListener() + private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null + + private val bluetoothTimeout = { onBluetoothTimeout() } + + fun start() { + handler.assertHandlerThread() + + Log.d(TAG, "start(): $state") + + if (state != State.UNINITIALIZED) { + Log.w(TAG, "Invalid starting state") + return + } + + bluetoothHeadset = null + scoConnectionAttempts = 0 + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + if (bluetoothAdapter == null) { + Log.i(TAG, "Device does not support Bluetooth") + return + } + + if (!androidAudioManager.isBluetoothScoAvailableOffCall) { + Log.w(TAG, "Bluetooth SCO audio is not available off call") + return + } + + if (bluetoothAdapter?.getProfileProxy(context, bluetoothListener, BluetoothProfile.HEADSET) != true) { + Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed") + return + } + + val bluetoothHeadsetFilter = IntentFilter().apply { + addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) + addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) + } + + bluetoothReceiver = BluetoothHeadsetBroadcastReceiver() + context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter) + + Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}") + Log.i(TAG, "Bluetooth proxy for headset profile has started") + state = State.UNAVAILABLE + } + + fun stop() { + handler.assertHandlerThread() + + Log.d(TAG, "stop(): state: $state") + + if (bluetoothAdapter == null) { + return + } + + stopScoAudio() + + if (state == State.UNINITIALIZED) { + return + } + + bluetoothReceiver?.let { receiver -> + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + Log.e(TAG,"error unregistering bluetoothReceiver", e) + } + } + bluetoothReceiver = null + + cancelTimer() + + bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset) + bluetoothHeadset = null + + bluetoothAdapter = null + state = State.UNINITIALIZED + } + + fun startScoAudio(): Boolean { + handler.assertHandlerThread() + + Log.i(TAG, "startScoAudio(): $state attempts: $scoConnectionAttempts") + + if (scoConnectionAttempts >= MAX_CONNECTION_ATTEMPTS) { + Log.w(TAG, "SCO connection attempts maxed out") + return false + } + + if (state != State.AVAILABLE) { + Log.w(TAG, "SCO connection failed as no headset available") + return false + } + + state = State.CONNECTING + androidAudioManager.startBluetoothSco() + scoConnectionAttempts++ + startTimer() + + return true + } + + fun stopScoAudio() { + handler.assertHandlerThread() + + Log.i(TAG, "stopScoAudio(): $state") + + if (state != State.CONNECTING && state != State.CONNECTED) { + return + } + + cancelTimer() + androidAudioManager.stopBluetoothSco() + androidAudioManager.isBluetoothScoOn = false + state = State.DISCONNECTING + } + + fun updateDevice() { + handler.assertHandlerThread() + + Log.d(TAG, "updateDevice(): state: $state") + + if (state == State.UNINITIALIZED || bluetoothHeadset == null) { + return + } + + if (bluetoothAdapter!!.getProfileConnectionState(BluetoothProfile.HEADSET) !in arrayOf(BluetoothProfile.STATE_CONNECTED)) { + state = State.UNAVAILABLE + Log.i(TAG, "No connected bluetooth headset") + } else { + state = State.AVAILABLE + Log.i(TAG, "Connected bluetooth headset.") + } + } + + private fun updateAudioDeviceState() { + audioManager.handleCommand(AudioManagerCommand.UpdateAudioDeviceState) + } + + private fun startTimer() { + handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT) + } + + private fun cancelTimer() { + handler.removeCallbacks(bluetoothTimeout) + } + + private fun onBluetoothTimeout() { + Log.i(TAG, "onBluetoothTimeout: state: $state bluetoothHeadset: $bluetoothHeadset") + + if (state == State.UNINITIALIZED || bluetoothHeadset == null || state != State.CONNECTING) { + return + } + + var scoConnected = false + + if (audioManager.isBluetoothScoOn()) { + Log.d(TAG, "Connected with device") + scoConnected = true + } else { + Log.d(TAG, "Not connected with device") + } + + if (scoConnected) { + Log.i(TAG, "Device actually connected and not timed out") + state = State.CONNECTED + scoConnectionAttempts = 0 + } else { + Log.w(TAG, "Failed to connect after timeout") + stopScoAudio() + } + + updateAudioDeviceState() + } + + private fun onServiceConnected(proxy: BluetoothHeadset?) { + bluetoothHeadset = proxy + androidAudioManager.isBluetoothScoOn = true + updateAudioDeviceState() + } + + private fun onServiceDisconnected() { + stopScoAudio() + bluetoothHeadset = null + state = State.UNAVAILABLE + updateAudioDeviceState() + } + + private fun onHeadsetConnectionStateChanged(connectionState: Int) { + Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}") + + when (connectionState) { + BluetoothHeadset.STATE_CONNECTED -> { + scoConnectionAttempts = 0 + updateAudioDeviceState() + } + BluetoothHeadset.STATE_DISCONNECTED -> { + stopScoAudio() + updateAudioDeviceState() + } + } + } + + private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) { + Log.i(TAG, "onAudioStateChanged: state: $state audioState: ${audioState.toStateString()} initialSticky: $isInitialStateChange") + + if (audioState == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + cancelTimer() + if (state == State.CONNECTING) { + Log.d(TAG, "Bluetooth audio SCO is now connected") + state = State.CONNECTED + scoConnectionAttempts = 0 + updateAudioDeviceState() + } else { + Log.w(TAG, "Unexpected state ${audioState.toStateString()}") + } + } else if (audioState == AudioManager.SCO_AUDIO_STATE_CONNECTING) { + Log.d(TAG, "Bluetooth audio SCO is now connecting...") + } else if (audioState == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { + Log.d(TAG, "Bluetooth audio SCO is now disconnected") + if (isInitialStateChange) { + Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.") + return + } + updateAudioDeviceState() + } + } + + private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + if (profile == BluetoothProfile.HEADSET) { + handler.post { + if (state != State.UNINITIALIZED) { + onServiceConnected(proxy as? BluetoothHeadset) + } + } + } + } + + override fun onServiceDisconnected(profile: Int) { + if (profile == BluetoothProfile.HEADSET) { + handler.post { + if (state != State.UNINITIALIZED) { + onServiceDisconnected() + } + } + } + } + } + + private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) { + val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED) + handler.post { + if (state != State.UNINITIALIZED) { + onHeadsetConnectionStateChanged(connectionState) + } + } + } else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) { +// val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED) +// handler.post { +// if (state != State.UNINITIALIZED) { +// onAudioStateChanged(connectionState, isInitialStickyBroadcast) +// } +// } + } else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { + val scoState: Int = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.ERROR) + handler.post { + if (state != State.UNINITIALIZED) { + onAudioStateChanged(scoState, isInitialStickyBroadcast) + } + } + } + } + } + + enum class State { + UNINITIALIZED, + UNAVAILABLE, + AVAILABLE, + DISCONNECTING, + CONNECTING, + CONNECTED, + ERROR; + + fun shouldUpdate(): Boolean { + return this == AVAILABLE || this == UNAVAILABLE || this == DISCONNECTING + } + + fun hasDevice(): Boolean { + return this == CONNECTED || this == CONNECTING || this == AVAILABLE + } + } + + companion object { + private val TAG = Log.tag(SignalBluetoothManager::class.java) + private val SCO_TIMEOUT = TimeUnit.SECONDS.toMillis(4) + private const val MAX_CONNECTION_ATTEMPTS = 2 + } +} + +private fun Int.toStateString(): String { + return when (this) { + BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED" + BluetoothAdapter.STATE_CONNECTED -> "CONNECTED" + BluetoothAdapter.STATE_CONNECTING -> "CONNECTING" + BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING" + BluetoothAdapter.STATE_OFF -> "OFF" + BluetoothAdapter.STATE_ON -> "ON" + BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF" + BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON" + else -> "UNKNOWN" + } +} + +private fun Int.toScoString(): String = when (this) { + AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED" + AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED" + AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING" + AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR" + else -> "UNKNOWN" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt new file mode 100644 index 000000000..dc9f07d05 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.webrtc.data + +// get the video rotation from a specific rotation, locked into 90 degree +// chunks offset by 45 degrees +fun Int.quadrantRotation() = when (this % 360) { + in 315 .. 360, + in 0 until 45 -> 0 + in 45 until 135 -> 90 + in 135 until 225 -> 180 + else -> 270 +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt new file mode 100644 index 000000000..9b60d5a56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.webrtc.data + +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_DECLINE_STATES +import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_HANGUP_STATES + +sealed class State { + object Idle : State() + object RemotePreOffer : State() + object RemoteRing : State() + object LocalPreOffer : State() + object LocalRing : State() + object Connecting : State() + object Connected : State() + object Reconnecting : State() + object PendingReconnect : State() + object Disconnected : State() + companion object { + + val ALL_STATES = arrayOf( + Idle, RemotePreOffer, RemoteRing, LocalPreOffer, LocalRing, + Connecting, Connected, Reconnecting, Disconnected + ) + + val CAN_DECLINE_STATES = arrayOf(RemotePreOffer, RemoteRing) + val PENDING_CONNECTION_STATES = arrayOf( + LocalPreOffer, + LocalRing, + RemotePreOffer, + RemoteRing, + Connecting, + ) + val OUTGOING_STATES = arrayOf( + LocalPreOffer, + LocalRing, + ) + val CAN_HANGUP_STATES = + arrayOf( + RemotePreOffer, + RemoteRing, + LocalPreOffer, + LocalRing, + Connecting, + Connected, + Reconnecting + ) + val CAN_RECEIVE_ICE_STATES = + arrayOf(RemoteRing, LocalRing, Connecting, Connected, Reconnecting) + } + + fun withState(vararg expectedState: State, body: () -> Unit) { + if (this in expectedState) { + body() + } + } + +} + +sealed class Event(vararg val expectedStates: State, val outputState: State) { + object ReceivePreOffer : + Event(State.Idle, outputState = State.RemotePreOffer) + + object ReceiveOffer : + Event(State.RemotePreOffer, State.Reconnecting, outputState = State.RemoteRing) + + object SendPreOffer : Event(State.Idle, outputState = State.LocalPreOffer) + object SendOffer : Event(State.LocalPreOffer, outputState = State.LocalRing) + object SendAnswer : Event(State.RemoteRing, outputState = State.Connecting) + object ReceiveAnswer : + Event(State.LocalRing, State.Reconnecting, outputState = State.Connecting) + + object Connect : Event(State.Connecting, State.Reconnecting, outputState = State.Connected) + object IceFailed : Event(State.Connecting, outputState = State.Disconnected) + object IceDisconnect : Event(State.Connected, outputState = State.PendingReconnect) + object NetworkReconnect : Event(State.PendingReconnect, outputState = State.Reconnecting) + object PrepareForNewOffer : Event(State.PendingReconnect, outputState = State.Reconnecting) + object TimeOut : + Event( + State.Connecting, + State.LocalRing, + State.RemoteRing, + State.Reconnecting, + outputState = State.Disconnected + ) + + object Error : Event(*State.ALL_STATES, outputState = State.Disconnected) + object DeclineCall : Event(*CAN_DECLINE_STATES, outputState = State.Disconnected) + object Hangup : Event(*CAN_HANGUP_STATES, outputState = State.Disconnected) + object Cleanup : Event(State.Disconnected, outputState = State.Idle) +} + +open class StateProcessor(initialState: State) { + private var _currentState: State = initialState + val currentState get() = _currentState + + open fun processEvent(event: Event, sideEffect: () -> Unit = {}): Boolean { + if (currentState in event.expectedStates) { + Log.i( + "Loki-Call", + "succeeded transitioning from ${currentState::class.simpleName} to ${event.outputState::class.simpleName} with ${event::class.simpleName}" + ) + _currentState = event.outputState + sideEffect() + return true + } + Log.e( + "Loki-Call", + "error transitioning from ${currentState::class.simpleName} to ${event.outputState::class.simpleName} with ${event::class.simpleName}" + ) + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java new file mode 100644 index 000000000..39afab0cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.webrtc.locks; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Handler; +import android.os.Message; + +import org.session.libsignal.utilities.Log; + +/** + * This class is used to listen to the accelerometer to monitor the + * orientation of the phone. The client of this class is notified when + * the orientation changes between horizontal and vertical. + */ +public final class AccelerometerListener { + private static final String TAG = "AccelerometerListener"; + private static final boolean DEBUG = true; + private static final boolean VDEBUG = false; + + private SensorManager mSensorManager; + private Sensor mSensor; + + // mOrientation is the orientation value most recently reported to the client. + private int mOrientation; + + // mPendingOrientation is the latest orientation computed based on the sensor value. + // This is sent to the client after a rebounce delay, at which point it is copied to + // mOrientation. + private int mPendingOrientation; + + private OrientationListener mListener; + + // Device orientation + public static final int ORIENTATION_UNKNOWN = 0; + public static final int ORIENTATION_VERTICAL = 1; + public static final int ORIENTATION_HORIZONTAL = 2; + + private static final int ORIENTATION_CHANGED = 1234; + + private static final int VERTICAL_DEBOUNCE = 100; + private static final int HORIZONTAL_DEBOUNCE = 500; + private static final double VERTICAL_ANGLE = 50.0; + + public interface OrientationListener { + public void orientationChanged(int orientation); + } + + public AccelerometerListener(Context context, OrientationListener listener) { + mListener = listener; + mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); + mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + + public void enable(boolean enable) { + if (DEBUG) Log.d(TAG, "enable(" + enable + ")"); + synchronized (this) { + if (enable) { + mOrientation = ORIENTATION_UNKNOWN; + mPendingOrientation = ORIENTATION_UNKNOWN; + mSensorManager.registerListener(mSensorListener, mSensor, + SensorManager.SENSOR_DELAY_NORMAL); + } else { + mSensorManager.unregisterListener(mSensorListener); + mHandler.removeMessages(ORIENTATION_CHANGED); + } + } + } + + private void setOrientation(int orientation) { + synchronized (this) { + if (mPendingOrientation == orientation) { + // Pending orientation has not changed, so do nothing. + return; + } + + // Cancel any pending messages. + // We will either start a new timer or cancel alltogether + // if the orientation has not changed. + mHandler.removeMessages(ORIENTATION_CHANGED); + + if (mOrientation != orientation) { + // Set timer to send an event if the orientation has changed since its + // previously reported value. + mPendingOrientation = orientation; + Message m = mHandler.obtainMessage(ORIENTATION_CHANGED); + // set delay to our debounce timeout + int delay = (orientation == ORIENTATION_VERTICAL ? VERTICAL_DEBOUNCE + : HORIZONTAL_DEBOUNCE); + mHandler.sendMessageDelayed(m, delay); + } else { + // no message is pending + mPendingOrientation = ORIENTATION_UNKNOWN; + } + } + } + + private void onSensorEvent(double x, double y, double z) { + if (VDEBUG) Log.d(TAG, "onSensorEvent(" + x + ", " + y + ", " + z + ")"); + + // If some values are exactly zero, then likely the sensor is not powered up yet. + // ignore these events to avoid false horizontal positives. + if (x == 0.0 || y == 0.0 || z == 0.0) return; + + // magnitude of the acceleration vector projected onto XY plane + double xy = Math.sqrt(x * x + y * y); + // compute the vertical angle + double angle = Math.atan2(xy, z); + // convert to degrees + angle = angle * 180.0 / Math.PI; + int orientation = (angle > VERTICAL_ANGLE ? ORIENTATION_VERTICAL : ORIENTATION_HORIZONTAL); + if (VDEBUG) Log.d(TAG, "angle: " + angle + " orientation: " + orientation); + setOrientation(orientation); + } + + SensorEventListener mSensorListener = new SensorEventListener() { + public void onSensorChanged(SensorEvent event) { + onSensorEvent(event.values[0], event.values[1], event.values[2]); + } + + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // ignore + } + }; + + Handler mHandler = new Handler() { + public void handleMessage(Message msg) { + switch (msg.what) { + case ORIENTATION_CHANGED: + synchronized (this) { + mOrientation = mPendingOrientation; + if (DEBUG) { + Log.d(TAG, "orientation: " + + (mOrientation == ORIENTATION_HORIZONTAL ? "horizontal" + : (mOrientation == ORIENTATION_VERTICAL ? "vertical" + : "unknown"))); + } + mListener.orientationChanged(mOrientation); + } + break; + } + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java new file mode 100644 index 000000000..a7fac62bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java @@ -0,0 +1,148 @@ +package org.thoughtcrime.securesms.webrtc.locks; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.provider.Settings; + +import org.session.libsignal.utilities.Log; + +/** + * Maintains wake lock state. + * + * @author Stuart O. Anderson + */ +public class LockManager { + + private static final String TAG = LockManager.class.getSimpleName(); + + private final PowerManager.WakeLock fullLock; + private final PowerManager.WakeLock partialLock; + private final WifiManager.WifiLock wifiLock; + private final ProximityLock proximityLock; + + private final AccelerometerListener accelerometerListener; + private final boolean wifiLockEnforced; + + + private int orientation = AccelerometerListener.ORIENTATION_UNKNOWN; + private boolean proximityDisabled = false; + + public enum PhoneState { + IDLE, + PROCESSING, //used when the phone is active but before the user should be alerted. + INTERACTIVE, + IN_CALL, + IN_VIDEO + } + + private enum LockState { + FULL, + PARTIAL, + SLEEP, + PROXIMITY + } + + public LockManager(Context context) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + fullLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, "signal:full"); + partialLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:partial"); + proximityLock = new ProximityLock(pm); + + WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "signal:wifi"); + + fullLock.setReferenceCounted(false); + partialLock.setReferenceCounted(false); + wifiLock.setReferenceCounted(false); + + accelerometerListener = new AccelerometerListener(context, new AccelerometerListener.OrientationListener() { + @Override + public void orientationChanged(int newOrientation) { + orientation = newOrientation; + Log.d(TAG, "Orentation Update: " + newOrientation); + updateInCallLockState(); + } + }); + + wifiLockEnforced = isWifiPowerActiveModeEnabled(context); + } + + private boolean isWifiPowerActiveModeEnabled(Context context) { + int wifi_pwr_active_mode = Settings.Secure.getInt(context.getContentResolver(), "wifi_pwr_active_mode", -1); + Log.d(TAG, "Wifi Activity Policy: " + wifi_pwr_active_mode); + + if (wifi_pwr_active_mode == 0) { + return false; + } + + return true; + } + + private void updateInCallLockState() { + if (orientation != AccelerometerListener.ORIENTATION_HORIZONTAL && wifiLockEnforced && !proximityDisabled) { + setLockState(LockState.PROXIMITY); + } else { + setLockState(LockState.FULL); + } + } + + public void updatePhoneState(PhoneState state) { + switch(state) { + case IDLE: + setLockState(LockState.SLEEP); + accelerometerListener.enable(false); + break; + case PROCESSING: + setLockState(LockState.PARTIAL); + accelerometerListener.enable(false); + break; + case INTERACTIVE: + setLockState(LockState.FULL); + accelerometerListener.enable(false); + break; + case IN_VIDEO: + proximityDisabled = true; + accelerometerListener.enable(false); + updateInCallLockState(); + break; + case IN_CALL: + proximityDisabled = false; + accelerometerListener.enable(true); + updateInCallLockState(); + break; + } + } + + private synchronized void setLockState(LockState newState) { + switch(newState) { + case FULL: + fullLock.acquire(); + partialLock.acquire(); + wifiLock.acquire(); + proximityLock.release(); + break; + case PARTIAL: + partialLock.acquire(); + wifiLock.acquire(); + fullLock.release(); + proximityLock.release(); + break; + case SLEEP: + fullLock.release(); + partialLock.release(); + wifiLock.release(); + proximityLock.release(); + break; + case PROXIMITY: + partialLock.acquire(); + proximityLock.acquire(); + wifiLock.acquire(); + fullLock.release(); + break; + default: + throw new IllegalArgumentException("Unhandled Mode: " + newState); + } + Log.d(TAG, "Entered Lock State: " + newState); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java new file mode 100644 index 000000000..ab91437c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.webrtc.locks; + +import android.os.PowerManager; + +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.guava.Optional; + +/** + * Controls access to the proximity lock. + * The proximity lock is not part of the public API. + * + * @author Stuart O. Anderson + */ +class ProximityLock { + + private static final String TAG = ProximityLock.class.getSimpleName(); + + private final Optional proximityLock; + + ProximityLock(PowerManager pm) { + proximityLock = getProximityLock(pm); + } + + private Optional getProximityLock(PowerManager pm) { + if (pm.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + return Optional.fromNullable(pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "signal:proximity")); + } else { + return Optional.absent(); + } + } + + public void acquire() { + if (!proximityLock.isPresent() || proximityLock.get().isHeld()) { + return; + } + + proximityLock.get().acquire(); + } + + public void release() { + if (!proximityLock.isPresent() || !proximityLock.get().isHeld()) { + return; + } + + proximityLock.get().release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); + + Log.d(TAG, "Released proximity lock:" + proximityLock.get().isHeld()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt new file mode 100644 index 000000000..421c14419 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.webrtc.video + +import android.content.Context +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.video.CameraState.Direction.* +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraEnumerator +import org.webrtc.CameraVideoCapturer + +class Camera(context: Context, + private val cameraEventListener: CameraEventListener): CameraVideoCapturer.CameraSwitchHandler { + + companion object { + private val TAG = Log.tag(Camera::class.java) + } + + val capturer: CameraVideoCapturer? + val cameraCount: Int + var activeDirection: CameraState.Direction = PENDING + var enabled: Boolean = false + set(value) { + field = value + capturer ?: return + try { + if (value) { + capturer.startCapture(1280,720,30) + } else { + capturer.stopCapture() + } + } catch (e: InterruptedException) { + Log.e(TAG,"Interrupted while stopping video capture") + } + } + + init { + val enumerator = Camera2Enumerator(context) + cameraCount = enumerator.deviceNames.size + capturer = createVideoCapturer(enumerator, FRONT)?.apply { + activeDirection = FRONT + } ?: createVideoCapturer(enumerator, BACK)?.apply { + activeDirection = BACK + } ?: run { + activeDirection = NONE + null + } + } + + fun dispose() { + capturer?.dispose() + } + + fun flip() { + if (capturer == null || cameraCount < 2) { + Log.w(TAG, "Tried to flip camera without capturer or less than 2 cameras") + return + } + activeDirection = PENDING + capturer.switchCamera(this) + } + + override fun onCameraSwitchDone(isFrontFacing: Boolean) { + activeDirection = if (isFrontFacing) FRONT else BACK + cameraEventListener.onCameraSwitchCompleted(CameraState(activeDirection, cameraCount)) + } + + override fun onCameraSwitchError(errorMessage: String?) { + Log.e(TAG,"onCameraSwitchError: $errorMessage") + cameraEventListener.onCameraSwitchCompleted(CameraState(activeDirection, cameraCount)) + + } + + private fun createVideoCapturer(enumerator: CameraEnumerator, direction: CameraState.Direction): CameraVideoCapturer? = + enumerator.deviceNames.firstOrNull { device -> + (direction == FRONT && enumerator.isFrontFacing(device)) || + (direction == BACK && enumerator.isBackFacing(device)) + }?.let { enumerator.createCapturer(it, null) } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/CameraEventListener.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/CameraEventListener.kt new file mode 100644 index 000000000..d52342815 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/CameraEventListener.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.webrtc.video + +interface CameraEventListener { + fun onCameraSwitchCompleted(newCameraState: CameraState) +} + +data class CameraState(val activeDirection: Direction, val cameraCount: Int) { + companion object { + val UNKNOWN = CameraState(Direction.NONE, 0) + } + + val enabled: Boolean + get() = activeDirection != Direction.NONE + + enum class Direction { + FRONT, BACK, NONE, PENDING + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt new file mode 100644 index 000000000..2b0caef89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.webrtc.video + +import org.thoughtcrime.securesms.webrtc.data.quadrantRotation +import org.webrtc.VideoFrame +import org.webrtc.VideoSink + +class RemoteRotationVideoProxySink: VideoSink { + + private var targetSink: VideoSink? = null + + var rotation: Int = 0 + + override fun onFrame(frame: VideoFrame?) { + val thisSink = targetSink ?: return + val thisFrame = frame ?: return + + val quadrantRotation = rotation.quadrantRotation() + val modifiedRotation = thisFrame.rotation - quadrantRotation + + val newFrame = VideoFrame(thisFrame.buffer, modifiedRotation, thisFrame.timestampNs) + thisSink.onFrame(newFrame) + } + + fun setSink(videoSink: VideoSink) { + targetSink = videoSink + } + + fun release() { + targetSink = null + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt new file mode 100644 index 000000000..ec43daf2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.webrtc.video + +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.data.quadrantRotation +import org.webrtc.CapturerObserver +import org.webrtc.VideoFrame +import org.webrtc.VideoProcessor +import org.webrtc.VideoSink +import java.lang.ref.SoftReference +import java.util.concurrent.atomic.AtomicBoolean + +class RotationVideoSink: CapturerObserver, VideoProcessor { + + var rotation: Int = 0 + var mirrored = false + + private val capturing = AtomicBoolean(false) + private var capturerObserver = SoftReference(null) + private var sink = SoftReference(null) + + override fun onCapturerStarted(ignored: Boolean) { + capturing.set(true) + } + + override fun onCapturerStopped() { + capturing.set(false) + } + + override fun onFrameCaptured(videoFrame: VideoFrame?) { + // rotate if need + val observer = capturerObserver.get() + if (videoFrame == null || observer == null || !capturing.get()) return + + val quadrantRotation = rotation.quadrantRotation() + + val newFrame = VideoFrame(videoFrame.buffer, (videoFrame.rotation + quadrantRotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1) % 360, videoFrame.timestampNs) + val localFrame = VideoFrame(videoFrame.buffer, videoFrame.rotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1, videoFrame.timestampNs) + + observer.onFrameCaptured(newFrame) + sink.get()?.onFrame(localFrame) + } + + override fun setSink(sink: VideoSink?) { + this.sink = SoftReference(sink) + } + + fun setObserver(videoSink: CapturerObserver?) { + capturerObserver = SoftReference(videoSink) + } +} \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_background.xml b/app/src/main/res/color/state_list_call_action_background.xml new file mode 100644 index 000000000..c1a337ee7 --- /dev/null +++ b/app/src/main/res/color/state_list_call_action_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_foreground.xml b/app/src/main/res/color/state_list_call_action_foreground.xml new file mode 100644 index 000000000..c312df2a5 --- /dev/null +++ b/app/src/main/res/color/state_list_call_action_foreground.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_mic_background.xml b/app/src/main/res/color/state_list_call_action_mic_background.xml new file mode 100644 index 000000000..1e40a3a05 --- /dev/null +++ b/app/src/main/res/color/state_list_call_action_mic_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp new file mode 100644 index 000000000..4f2c8b5d6 Binary files /dev/null and b/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp new file mode 100644 index 000000000..c1431deff Binary files /dev/null and b/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp new file mode 100644 index 000000000..2fab32234 Binary files /dev/null and b/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable/call_controls_background.xml b/app/src/main/res/drawable/call_controls_background.xml new file mode 100644 index 000000000..34137af62 --- /dev/null +++ b/app/src/main/res/drawable/call_controls_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml index bab545a70..2a31b2ef3 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> diff --git a/app/src/main/res/drawable/ic_baseline_call_24.xml b/app/src/main/res/drawable/ic_baseline_call_24.xml new file mode 100644 index 000000000..567e303fc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_call_end_24.xml b/app/src/main/res/drawable/ic_baseline_call_end_24.xml new file mode 100644 index 000000000..dd6ff4092 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_end_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_call_made_24.xml b/app/src/main/res/drawable/ic_baseline_call_made_24.xml new file mode 100644 index 000000000..2183fbc7f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_made_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_call_missed_24.xml b/app/src/main/res/drawable/ic_baseline_call_missed_24.xml new file mode 100644 index 000000000..28958d07a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_missed_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_call_received_24.xml b/app/src/main/res/drawable/ic_baseline_call_received_24.xml new file mode 100644 index 000000000..05192f9f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_received_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml new file mode 100644 index 000000000..951aa1f62 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_mic_off_24.xml b/app/src/main/res/drawable/ic_baseline_mic_off_24.xml new file mode 100644 index 000000000..8e199f115 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_mic_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_videocam_24.xml b/app/src/main/res/drawable/ic_baseline_videocam_24.xml new file mode 100644 index 000000000..340bff20c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_videocam_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml b/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml new file mode 100644 index 000000000..7977a645c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_volume_mute_24.xml b/app/src/main/res/drawable/ic_baseline_volume_mute_24.xml new file mode 100644 index 000000000..fc41db8b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_volume_mute_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_volume_up_24.xml b/app/src/main/res/drawable/ic_baseline_volume_up_24.xml new file mode 100644 index 000000000..0db34695f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_volume_up_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml new file mode 100644 index 000000000..da1a78fe5 --- /dev/null +++ b/app/src/main/res/drawable/ic_incoming_call.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_missed_call.xml b/app/src/main/res/drawable/ic_missed_call.xml new file mode 100644 index 000000000..4d3aee2a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_missed_call.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml new file mode 100644 index 000000000..ad27f18d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_outline_mic_none_24.xml b/app/src/main/res/drawable/ic_outline_mic_none_24.xml new file mode 100644 index 000000000..2810bc9a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_mic_none_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_mic_off_24.xml b/app/src/main/res/drawable/ic_outline_mic_off_24.xml new file mode 100644 index 000000000..631ced961 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_mic_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_videocam_24.xml b/app/src/main/res/drawable/ic_outline_videocam_24.xml new file mode 100644 index 000000000..cce4a0b74 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_videocam_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_videocam_off_24.xml b/app/src/main/res/drawable/ic_outline_videocam_off_24.xml new file mode 100644 index 000000000..e3ebd9e65 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_videocam_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/prominent_outline_button_medium_background_accent.xml b/app/src/main/res/drawable/prominent_outline_button_medium_background_accent.xml new file mode 100644 index 000000000..f595e4f38 --- /dev/null +++ b/app/src/main/res/drawable/prominent_outline_button_medium_background_accent.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml new file mode 100644 index 000000000..e1c7c21d7 --- /dev/null +++ b/app/src/main/res/layout/activity_webrtc.xml @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_call_bottom_sheet.xml b/app/src/main/res/layout/fragment_call_bottom_sheet.xml new file mode 100644 index 000000000..ec555bc84 --- /dev/null +++ b/app/src/main/res/layout/fragment_call_bottom_sheet.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_call.xml b/app/src/main/res/menu/menu_conversation_call.xml new file mode 100644 index 000000000..8ebfeb8c8 --- /dev/null +++ b/app/src/main/res/menu/menu_conversation_call.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/redphone_busy.mp3 b/app/src/main/res/raw/redphone_busy.mp3 new file mode 100644 index 000000000..865005e51 Binary files /dev/null and b/app/src/main/res/raw/redphone_busy.mp3 differ diff --git a/app/src/main/res/raw/redphone_outring.mp3 b/app/src/main/res/raw/redphone_outring.mp3 new file mode 100644 index 000000000..7442bf7d5 Binary files /dev/null and b/app/src/main/res/raw/redphone_outring.mp3 differ diff --git a/app/src/main/res/raw/webrtc_completed.mp3 b/app/src/main/res/raw/webrtc_completed.mp3 new file mode 100644 index 000000000..22ec4647e Binary files /dev/null and b/app/src/main/res/raw/webrtc_completed.mp3 differ diff --git a/app/src/main/res/raw/webrtc_disconnected.mp3 b/app/src/main/res/raw/webrtc_disconnected.mp3 new file mode 100644 index 000000000..998515131 Binary files /dev/null and b/app/src/main/res/raw/webrtc_disconnected.mp3 differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7375a81ba..037c4186e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -40,6 +40,12 @@ #404040 #B3B3B3 + #DD353535 + #FFFFFF + #D8D8D8 + #171717 + #171717 + #5ff8b0 #26cdb9 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 524343797..7209f3ea5 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,6 +12,7 @@ 34dp 38dp + 54dp 22dp 4dp 26dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3205620dd..9b315026d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ No Delete Ban - Please wait... + Please wait… Save Note to Self Version %s @@ -99,6 +99,9 @@ %1$d of %2$d + Call Permissions Required + You can enable the \'Voice and video calls\' permission in the Privacy Settings. + Delete selected message? @@ -560,6 +563,7 @@ Ban and delete all Resend message Reply to message + Call Save attachment @@ -899,5 +903,26 @@ Closed Group Open Group You have a new message request + Connecting… + Incoming call + Deny call + Answer call + Call in progress + Cancel call + Establishing call + End call + Accept Call + Decline call + Voice and video calls + Allow access to accept voice and video calls from other users + Voice / video calls + The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user + Call Missed + You missed a call because you need to enable the \'Voice and video calls\' permission in the Privacy Settings. + Session Call + Reconnecting… + Notifications + Having notifications disabled will prevent you from receiving calls, go to Session notification settings? + Dismiss diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1622da9e3..4ad16737c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -83,6 +83,12 @@ @color/accent + + + + + +