diff --git a/app/build.gradle b/app/build.gradle
index bdfac64e7e..9e479509df 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.14"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 54ec5ea7ab..9bbbdbc365 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 df8544a39a..e5419774f4 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 e7c5b7e9e1..67aae625bb 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 0c8bda33f5..c90739ab15 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 5966e765e3..5669cafa4f 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 e4247b86ff..a4e4a52d5b 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 92f9e970d7..5905433b93 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 558957515e..a34de25a98 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 657e699b52..0a3d3c45d8 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 9a47de7850..9e105025f9 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 141c77791e..ef0f4b54f3 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 31db5b4514..0f5247cf8b 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 0000000000..da15c2f6b4
--- /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 0522dbcac2..d70653f8a0 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 0b9a37971a..697f6718c2 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 a359596c91..549d6eac0a 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 efe75fef8e..31186e24c6 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 03da0f378d..81332e87d9 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 68b69344b2..61c126339d 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 db93b739ae..acac47e568 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 2e9e6b846e..5c42f38bcd 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 0000000000..079d7a21ad
--- /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 0000000000..56c0a55dda
--- /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 0000000000..b01edfb492
--- /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 0000000000..c6dd6525e9
--- /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 0000000000..006da2b63e
--- /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 0000000000..a66dd591f6
--- /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 0000000000..4f27e5d1ad
--- /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 0000000000..01b161e088
--- /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 0000000000..52ee5583d2
--- /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 0000000000..2e8d79d62d
--- /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 0000000000..26d8fc223d
--- /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 0000000000..dfc2dc6fb3
--- /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 0000000000..56271b9803
--- /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 0000000000..955356c7d1
--- /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 0000000000..da50cd0846
--- /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 0000000000..1b3995a2aa
--- /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 0000000000..465866931a
--- /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 0000000000..89eba2a3aa
--- /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 0000000000..2b4d34807c
--- /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 0000000000..84a36ee821
--- /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 0000000000..dc9f07d051
--- /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 0000000000..9b60d5a563
--- /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 0000000000..39afab0cb2
--- /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 0000000000..a7fac62bbc
--- /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 0000000000..ab91437c7d
--- /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 0000000000..421c144199
--- /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 0000000000..d52342815b
--- /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 0000000000..2b0caef89c
--- /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 0000000000..ec43daf2ef
--- /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 0000000000..c1a337ee76
--- /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 0000000000..c312df2a58
--- /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 0000000000..1e40a3a054
--- /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 0000000000..4f2c8b5d6e
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 0000000000..c1431deff9
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 0000000000..2fab32234a
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 0000000000..34137af62c
--- /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 bab545a707..2a31b2ef3e 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 0000000000..567e303fc5
--- /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 0000000000..dd6ff40926
--- /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 0000000000..2183fbc7f5
--- /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 0000000000..28958d07ab
--- /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 0000000000..05192f9f30
--- /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 0000000000..951aa1f621
--- /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 0000000000..8e199f115a
--- /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 0000000000..340bff20c2
--- /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 0000000000..7977a645c3
--- /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 0000000000..fc41db8b9f
--- /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 0000000000..0db34695f1
--- /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 0000000000..da1a78fe50
--- /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 0000000000..4d3aee2a09
--- /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 0000000000..ad27f18d52
--- /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 0000000000..2810bc9a2e
--- /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 0000000000..631ced9615
--- /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 0000000000..cce4a0b74f
--- /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 0000000000..e3ebd9e65b
--- /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 0000000000..f595e4f38b
--- /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 0000000000..e1c7c21d78
--- /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 0000000000..ec555bc847
--- /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 0000000000..8ebfeb8c8d
--- /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 0000000000..865005e512
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 0000000000..7442bf7d5b
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 0000000000..22ec4647ef
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 0000000000..9985151315
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 7375a81ba5..037c4186e0 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 5243437970..7209f3ea51 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 3205620dde..9b315026d6 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 1622da9e32..4ad16737c1 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -83,6 +83,12 @@
- @color/accent
+
+
+
+
+
+