Add one on one calls over clearnet (#864)

* feat: adding basic webrtc deps and test activity

* more testing code

* feat: add protos and bump version

* feat: added basic call functionality

* feat: adding UI and flipping cameras

* feat: add stats and starting call bottom sheet

* feat: hanging up and bottom sheet behaviors should work now

* feat: add call stats report on frontend

* feat: add relay toggle for answer and offer

* fix: add keep screen on and more end call message on back pressed / on finish

* refactor: removing and replacing dagger 1 dep with android hilt

* feat: include latest proto

* feat: update to utilise call ID

* feat: add stun and turn

* refactor: playing around with deps and transport types

* feat: adding call service functionality and permissions for calls

* feat: add call manager and more static intent building functions for WebRtcCallService.kt

* feat: adding ringers and more audio boilerplate

* feat: audio manager call service boilerplate

* feat: update kotlin and add in call view model and more management functions

* refactor: moving call code around to service and viewmodel interactions

* feat: plugging CallManager.kt into view model and service, fixing up dependencies

* feat: implementing more WebRtcCallService.kt functions and handlers for actions as well as lifecycle

* feat: adding more lifecycle vm and callmanager / call service functionality

* feat: adding more command handlers in WebRtcCallService.kt

* feat: more commands handled, adding lock manager and bluetooth permissions

* feat: adding remainder of basic functionality to services and CallManager.kt

* feat: hooking up calls and fixing broken dependencies and compile errors

* fix: add timestamp to incoming call

* feat: some connection and service launching / ring lifecycle

* feat: call establishing and displaying

* fix: fixing call connect flows

* feat: ringers and better state handling

* feat: updating call layout

* feat: add fixes to bluetooth and begin the network renegotiation

* feat: add call related permissions and more network handover tests

* fix: don't display call option in conversation and don't show notification if option not enabled

* fix: incoming ringer fix on receiving call, call notification priorities and notification channel update

* build: update build number for testing

* fix: bluetooth auto-connection and re-connection fixes, removing finished todos, allowing self-send call messages for deduping answers

* feat: add pre-offer information and action handling in web rtc call service

* refactor: discard offer messages from non-matching pre-offers we are already expecting

* build: build numbers and version name update

* feat: handle discarding pending calls from linked devices

* feat: add signing props to release config build

* docs: fix comment on time being 300s (5m) instead of 30s

* feat: adding call messages for incoming/outgoing/missed

* refactor: handle in-thread call notifications better and replace deny button intent with denyCallIntent instead of hangup

* feat: add a hangup via data channel message

* feat: process microphone enabled events and remove debuggable from build.gradle

* feat: add first call notification

* refactor: set the buttons to match iOS in terms of enable disable and colours

* refactor: change the call logos in control messages

* refactor: more bluetooth improvements

* refactor: move start ringer and init of audio manager to CallManager.kt and string fix up

* build: remove debuggable for release build

* refactor: replace call icons

* feat: adding a call time display

* refactor: change the call time to update every second

* refactor: testing out the full screen intents

* refactor: wrapper use corrected session description, set title to recipient displayName, indicate session calls

* fix: crash on view with a parent already attached

* refactor: aspect ratio fit preserved

* refactor: add wantsToAnswer ability in pre-init for fullscreenintent

* refactor: prevent calls from non hasSent participants

* build: update gradle code

* refactor: replace timeout schedule with a seconds count

* fix: various bug fixes for calls

* fix: remove end call from busy

* refactor: use answerCall instead of manual intent building again

* build: new version

* feat: add silenced notifications for call notification builder. check pre-offer and connecting state for pending connection

* build: update build number

* fix: text color uses overridden style value

* fix: remove wrap content for renderers and look more at recovering from network switches

* build: update build number

* refactor: remove whitespace

* build: update build number

* refactor: used shared number for BatchMessageReceiveJob.kt parameter across pollers

* fix: glide in update crash

* fix: bug fixes for self-send answer / hangup messages

* build: update build number

* build: update build.gradle number

* refactor: compile errors and refactoring to view binding

* fix: set the content to binding.root view

* build: increase build number

* build: update build numbers

* feat: adding base for rotation and picking random subset of turn servers

* feat: starting the screen rotation processing

* feat: setting up rotation for the remote render view

* refactor: applying rotation and mirroring based on front / rear cameras that wraps nicely, only scale reworking needed

* refactor: calls video stretching but consistent

* refactor: state machine and tests for the transition events

* feat: new call state processing

* refactor: adding reconnecting logic and visuals

* feat: state machine reconnect logic wip

* feat: add reconnecting and merge fixes

* feat: check new session based off current state

* feat: reconnection logic works correctly now

* refactor: reduce TIMEOUT_SECONDS to 30 from 90

* feat: reset peer connection on DC to prevent ICE messages from old connection or stale state in reconnecting

* refactor: add null case

* fix: set approved on new outgoing threads, use approved more deeply and invalidate the options menu on recipient modified. Add approvedMe flag toggles for visible message receive

* fix: add name update in action bar on modified, change where approvedMe is set

* build: increment build number

* build: update build number

* fix: merge compile errors and increment build number

* refactor: remove negotiation based on which party dropped connection

* refactor: call reconnection improvement tested cross platform to re-establish

* refactor: failed and disconnect events only handled if either the reconnect or the timeout runnables are not set

* build: update version number

* fix: reduce timeout

* fix: fixes the incoming hangup logic for linked devices

* refactor: match iOS styling for call activity closer

* chore: upgrade build numbers

* feat: add in call settings dialog for if calls is disabled in conversation

* feat: add a first call missed control message and info popup with link to privacy settings

* fix: looking at crash for specific large transaction in NotificationManager

* refactor: removing the people in case transaction size reduces to fix notif crash

* fix: comment out the entire send multiple to see if it fixes the issue

* refactor: revert to including the full notification process in a try/catch to handle weird responses from NotificationManager

* fix: add in notification settings prompt for calls and try to fall back to dirty full screen intent / start activity if we're allowed

* build: upgrade build number
This commit is contained in:
Harris 2022-04-19 14:25:40 +10:00 committed by GitHub
parent 04dfe99517
commit e1b6bb7e56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 8054 additions and 286 deletions

View file

@ -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,

View file

@ -30,6 +30,7 @@
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
@ -51,9 +52,9 @@
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<queries>
<intent>
@ -300,6 +301,16 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="true"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity android:name="org.thoughtcrime.securesms.calls.WebRtcCallActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:showForAllUsers="true"
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.CallActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<service
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
android:enabled="true"
@ -308,6 +319,8 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
android:exported="false" />
<service
android:name="org.thoughtcrime.securesms.service.KeyCachingService"
android:enabled="true"

View file

@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
import org.thoughtcrime.securesms.util.Broadcaster;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper;
import org.thoughtcrime.securesms.webrtc.CallMessageProcessor;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.PeerConnectionFactory.InitializationOptions;
import org.webrtc.voiceengine.WebRtcAudioManager;
@ -133,6 +134,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject Storage storage;
@Inject MessageDataProvider messageDataProvider;
@Inject JobDatabase jobDatabase;
@Inject TextSecurePreferences textSecurePreferences;
CallMessageProcessor callMessageProcessor;
private volatile boolean isAppVisible;
@ -159,6 +162,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public void onCreate() {
DatabaseModule.init(this);
super.onCreate();
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
startKovenant();
initializeSecurityProvider();

View file

@ -0,0 +1,377 @@
package org.thoughtcrime.securesms.calls
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.OrientationEventListener
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.bumptech.glide.load.engine.DiskCacheStrategy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityWebrtcBinding
import org.apache.commons.lang3.time.DurationFormatUtils
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.contacts.Contact
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator
import org.thoughtcrime.securesms.webrtc.AudioManagerCommand
import org.thoughtcrime.securesms.webrtc.CallViewModel
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_CONNECTED
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_INCOMING
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OUTGOING
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_INIT
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RECONNECTING
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RINGING
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.EARPIECE
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE
@AndroidEntryPoint
class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
companion object {
const val ACTION_PRE_OFFER = "pre-offer"
const val ACTION_FULL_SCREEN_INTENT = "fullscreen-intent"
const val ACTION_ANSWER = "answer"
const val ACTION_END = "end-call"
const val BUSY_SIGNAL_DELAY_FINISH = 5500L
private const val CALL_DURATION_FORMAT = "HH:mm:ss"
}
private val viewModel by viewModels<CallViewModel>()
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()
}
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -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<Void?, Void?, IconCompat?>() {

View file

@ -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

View file

@ -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;
}

View file

@ -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<InsertResult> insertCallMessage(IncomingTextMessage message) {
return insertMessageInbox(message, 0, 0);
}
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp);
}

View file

@ -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
}
}

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -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());

View file

@ -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)
}

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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) {

View file

@ -93,7 +93,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
}
if (privacy.isDisplayContact() && sender.getContactUri() != null) {
addPerson(sender.getContactUri().toString());
// addPerson(sender.getContactUri().toString());
}
}

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -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<Boolean, Void> setCallback;
private CallToggleListener(Fragment context, Function1<Boolean,Void> 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;
}
}
}
}

View file

@ -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<IceCandidate>, 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<AudioManagerCommand>(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<ResultReceiver>(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<Address>(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<V>(
expectedState: CallState,
expectedCallId: UUID?,
getState: () -> Pair<CallState, UUID?>): StateAwareListener<V>(expectedState, expectedCallId, getState) {
override fun onSuccessContinue(result: V) {}
}
private abstract class SuccessOnlyListener<V>(
expectedState: CallState,
expectedCallId: UUID?,
getState: () -> Pair<CallState, UUID>): StateAwareListener<V>(expectedState, expectedCallId, getState) {
override fun onFailureContinue(throwable: Throwable?) {
Log.e(TAG, throwable)
throw AssertionError(throwable)
}
}
private abstract class StateAwareListener<V>(
private val expectedState: CallState,
private val expectedCallId: UUID?,
private val getState: ()->Pair<CallState, UUID?>): FutureTaskListener<V> {
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<out IceCandidate>?) {}
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<out MediaStream>?) {}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.webrtc
enum class AudioEvent {
}

View file

@ -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()
}

View file

@ -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<AudioDevice>): 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<WebRtcListener>()
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<IceCandidate>()
private val pendingIncomingIceUpdates = ArrayDeque<IceCandidate>()
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<IceCandidate>()
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<out IceCandidate>?) {
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<out MediaStream>?) {
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<AudioDevice>) {
_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<Unit, Exception> {
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<Unit, Exception> {
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<Unit, Exception> {
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<IceCandidate>, 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()
}
}

View file

@ -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<IceCandidate> {
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])
}
}
}

View file

@ -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
}

View file

@ -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);
}
}
}
}
}

View file

@ -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<Network> = 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 {
//
// }
}
}

View file

@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.webrtc
class PeerConnectionException: Exception {
constructor(error: String?): super(error)
constructor(throwable: Throwable): super(throwable)
}

View file

@ -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<Boolean>()
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<Boolean>()
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<SessionDescription>()
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<SessionDescription>()
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<Boolean>()
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
}

View file

@ -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)

View file

@ -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<Thread.UncaughtExceptionHandler> handlers = new ArrayList<Thread.UncaughtExceptionHandler>();
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);
}
}
}
}

View file

@ -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)
}
}

View file

@ -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);
}
}
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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<AudioDevice> = 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<AudioDevice>)
}
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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;
}
}
};
}

View file

@ -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);
}
}

View file

@ -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<PowerManager.WakeLock> proximityLock;
ProximityLock(PowerManager pm) {
proximityLock = getProximityLock(pm);
}
private Optional<PowerManager.WakeLock> 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());
}
}

View file

@ -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) }
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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<CapturerObserver>(null)
private var sink = SoftReference<VideoSink>(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)
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/call_action_button_highlighted" android:state_selected="true"/>
<item android:color="@color/call_action_button" android:state_selected="false"/>
</selector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/call_action_foreground_highlighted" android:state_selected="true"/>
<item android:color="@color/call_action_foreground"/>
</selector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/destructive" android:state_selected="true"/>
<item android:color="@color/call_action_button" android:state_selected="false"/>
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="oval">
<solid android:color="@color/call_action_button_highlighted"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="@color/call_action_button"/>
</shape>
</item>
</selector>

View file

@ -3,7 +3,8 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20.01,15.38c-1.23,0 -2.42,-0.2 -3.53,-0.56 -0.35,-0.12 -0.74,-0.03 -1.01,0.24l-1.57,1.97c-2.83,-1.35 -5.48,-3.9 -6.89,-6.83l1.95,-1.66c0.27,-0.28 0.35,-0.67 0.24,-1.02 -0.37,-1.11 -0.56,-2.3 -0.56,-3.53 0,-0.54 -0.45,-0.99 -0.99,-0.99H4.19C3.65,3 3,3.24 3,3.99 3,13.28 10.73,21 20.01,21c0.71,0 0.99,-0.63 0.99,-1.18v-3.45c0,-0.54 -0.45,-0.99 -0.99,-0.99z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M9,5v2h6.59L4,18.59 5.41,20 17,8.41V15h2V5z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M19.59,7L12,14.59 6.41,9H11V7H3v8h2v-4.59l7,7 9,-9z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,5.41L18.59,4 7,15.59V9H5v10h10v-2H8.41z"/>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,12c0,1.66 1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3S9,10.34 9,12z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M8,10V8H5.09C6.47,5.61 9.05,4 12,4c3.72,0 6.85,2.56 7.74,6h2.06c-0.93,-4.56 -4.96,-8 -9.8,-8C8.73,2 5.82,3.58 4,6.01V4H2v6H8z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M16,14v2h2.91c-1.38,2.39 -3.96,4 -6.91,4c-3.72,0 -6.85,-2.56 -7.74,-6H2.2c0.93,4.56 4.96,8 9.8,8c3.27,0 6.18,-1.58 8,-4.01V20h2v-6H16z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9.01,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M21,6.5l-4,4V7c0,-0.55 -0.45,-1 -1,-1H9.82L21,17.18V6.5zM3.27,2L2,3.27 4.73,6H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.21,0 0.39,-0.08 0.54,-0.18L19.73,21 21,19.73 3.27,2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,9v6h4l5,5V4l-5,5H7z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M14.414,7l3.293,-3.293a1,1 0,0 0,-1.414 -1.414L13,5.586V4a1,1 0,1 0,-2 0v4.003a0.996,0.996 0,0 0,0.617 0.921A0.997,0.997 0,0 0,12 9h4a1,1 0,1 0,0 -2h-1.586z"
android:fillColor="#000000"/>
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
<path
android:pathData="M16.707,3.293a1,1 0,0 1,0 1.414L15.414,6l1.293,1.293a1,1 0,0 1,-1.414 1.414L14,7.414l-1.293,1.293a1,1 0,1 1,-1.414 -1.414L12.586,6l-1.293,-1.293a1,1 0,0 1,1.414 -1.414L14,4.586l1.293,-1.293a1,1 0,0 1,1.414 0z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M17.924,2.617a0.997,0.997 0,0 0,-0.215 -0.322l-0.004,-0.004A0.997,0.997 0,0 0,17 2h-4a1,1 0,1 0,0 2h1.586l-3.293,3.293a1,1 0,0 0,1.414 1.414L16,5.414V7a1,1 0,1 0,2 0V3a0.997,0.997 0,0 0,-0.076 -0.383z"
android:fillColor="#000000"/>
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM11,5c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v6c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1L11,5zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5L5,11c0,3.53 2.61,6.43 6,6.92L11,21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10.8,4.9c0,-0.66 0.54,-1.2 1.2,-1.2s1.2,0.54 1.2,1.2l-0.01,3.91L15,10.6V5c0,-1.66 -1.34,-3 -3,-3 -1.54,0 -2.79,1.16 -2.96,2.65l1.76,1.76V4.9zM19,11h-1.7c0,0.58 -0.1,1.13 -0.27,1.64l1.27,1.27c0.44,-0.88 0.7,-1.87 0.7,-2.91zM4.41,2.86L3,4.27l6,6V11c0,1.66 1.34,3 3,3 0.23,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c0.91,-0.13 1.77,-0.45 2.55,-0.9l4.2,4.2 1.41,-1.41L4.41,2.86z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15,8v8H5V8h10m1,-2H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4V7c0,-0.55 -0.45,-1 -1,-1z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9.56,8l-2,-2 -4.15,-4.14L2,3.27 4.73,6L4,6c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.21,0 0.39,-0.08 0.55,-0.18L19.73,21l1.41,-1.41 -8.86,-8.86L9.56,8zM5,16L5,8h1.73l8,8L5,16zM15,8v2.61l6,6L21,6.5l-4,4L17,7c0,-0.55 -0.45,-1 -1,-1h-5.61l2,2L15,8z"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/transparent" />
<corners android:radius="@dimen/medium_button_corner_radius" />
<stroke android:width="@dimen/border_thickness" android:color="@color/accent" />
</shape>

View file

@ -0,0 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:theme="@style/Theme.Session.CallActivity"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:keepScreenOn="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
android:id="@+id/remote_parent"
android:background="@color/black"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<FrameLayout
android:id="@+id/remote_renderer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"/>
</FrameLayout>
<ImageView
android:id="@+id/remote_recipient"
app:layout_constraintStart_toStartOf="@id/remote_parent"
app:layout_constraintEnd_toEndOf="@id/remote_parent"
app:layout_constraintTop_toTopOf="@id/remote_parent"
app:layout_constraintBottom_toBottomOf="@id/remote_parent"
app:layout_constraintVertical_bias="0.4"
android:layout_width="@dimen/extra_large_profile_picture_size"
android:layout_height="@dimen/extra_large_profile_picture_size"/>
<ImageView
android:id="@+id/back_arrow"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:background="@drawable/call_controls_background"
android:elevation="8dp"
android:layout_marginLeft="@dimen/small_spacing"
android:layout_marginTop="@dimen/small_spacing"
android:src="@drawable/ic_baseline_arrow_back_24"
android:scaleType="centerInside"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size"
app:tint="@color/call_action_foreground" />
<TextView
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="@dimen/massive_spacing"
android:layout_marginTop="@dimen/medium_spacing"
android:textAlignment="center"
android:id="@+id/remote_recipient_name"
android:textStyle="bold"
tools:text="@tools:sample/full_names"
android:ellipsize="end"
android:textSize="20sp"
android:layout_width="0dp"
android:layout_height="wrap_content"/>
<com.github.ybq.android.spinkit.SpinKitView
android:id="@+id/remote_loading_view"
style="@style/SpinKitView.ThreeBounce"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:foregroundGravity="center"
android:visibility="gone"
app:SpinKit_Color="@color/core_white"
app:layout_constraintEnd_toEndOf="@+id/remote_recipient"
app:layout_constraintStart_toStartOf="@+id/remote_recipient"
app:layout_constraintTop_toBottomOf="@id/remote_recipient"
tools:visibility="visible" />
<TextView
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/remote_loading_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/WebRtcCallActivity_Reconnecting"
android:id="@+id/reconnecting_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/sessionCallText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/WebRtcCallActivity_Session_Call"
android:textColor="?android:textColorPrimary"
android:textSize="11sp"
app:layout_constraintBottom_toTopOf="@+id/controlGroupBarrier"
android:layout_marginBottom="@dimen/small_spacing"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
/>
<TextView
android:id="@+id/callTime"
android:textSize="@dimen/medium_font_size"
android:textColor="?android:textColorPrimary"
tools:text="@tools:sample/date/hhmmss"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/sessionCallText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<FrameLayout
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="h,9:16"
android:layout_marginHorizontal="@dimen/large_spacing"
android:layout_marginVertical="@dimen/massive_spacing"
app:layout_constraintWidth_percent="0.2"
android:layout_height="0dp"
android:layout_width="0dp">
<FrameLayout
android:elevation="8dp"
android:id="@+id/local_renderer"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<com.github.ybq.android.spinkit.SpinKitView
android:id="@+id/local_loading_view"
style="@style/SpinKitView.Large.ThreeBounce"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:SpinKit_Color="@color/text"
android:layout_gravity="center"
tools:visibility="visible"
android:visibility="gone" />
</FrameLayout>
<ImageView
android:id="@+id/endCallButton"
android:background="@drawable/circle_tintable"
android:src="@drawable/ic_baseline_call_end_24"
android:padding="@dimen/medium_spacing"
android:foregroundTint="@color/call_action_foreground"
android:backgroundTint="@color/destructive"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="@dimen/large_spacing"
/>
<ImageView
android:id="@+id/switchCameraButton"
android:background="@drawable/call_controls_background"
android:src="@drawable/ic_baseline_flip_camera_android_24"
android:padding="@dimen/medium_spacing"
app:tint="@color/call_action_foreground"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
app:layout_constraintBottom_toTopOf="@+id/endCallButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="@dimen/large_spacing"
app:layout_constraintHorizontal_bias="0.1"
/>
<ImageView
android:id="@+id/enableCameraButton"
android:background="@drawable/circle_tintable"
android:backgroundTint="@color/state_list_call_action_background"
app:tint="@color/state_list_call_action_foreground"
android:src="@drawable/ic_baseline_videocam_24"
android:padding="@dimen/medium_spacing"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
app:layout_constraintBottom_toTopOf="@+id/endCallButton"
app:layout_constraintStart_toEndOf="@id/switchCameraButton"
app:layout_constraintEnd_toStartOf="@id/microphoneButton"
android:layout_marginBottom="@dimen/large_spacing"
/>
<ImageView
android:id="@+id/microphoneButton"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
android:padding="@dimen/medium_spacing"
android:src="@drawable/ic_baseline_mic_off_24"
android:layout_marginBottom="@dimen/large_spacing"
app:layout_constraintBottom_toTopOf="@+id/endCallButton"
android:background="@drawable/circle_tintable"
android:backgroundTint="@color/state_list_call_action_mic_background"
app:tint="@color/call_action_foreground"
app:layout_constraintEnd_toStartOf="@id/speakerPhoneButton"
app:layout_constraintStart_toEndOf="@id/enableCameraButton"/>
<ImageView
android:id="@+id/speakerPhoneButton"
android:background="@drawable/circle_tintable"
android:backgroundTint="@color/state_list_call_action_background"
app:tint="@color/state_list_call_action_foreground"
android:src="@drawable/ic_baseline_volume_up_24"
android:padding="@dimen/medium_spacing"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
app:layout_constraintBottom_toTopOf="@+id/endCallButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="@dimen/large_spacing"
app:layout_constraintHorizontal_bias="0.9"
/>
<ImageView
android:background="@drawable/circle_tintable"
android:src="@drawable/ic_baseline_call_24"
android:padding="@dimen/medium_spacing"
android:foregroundTint="@color/call_action_foreground"
android:backgroundTint="@color/accent"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
android:layout_marginBottom="@dimen/very_large_spacing"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintHorizontal_bias="0.75"
android:gravity="center"
android:id="@+id/acceptCallButton"/>
<ImageView
android:background="@drawable/circle_tintable"
android:src="@drawable/ic_baseline_call_end_24"
android:padding="@dimen/medium_spacing"
android:foregroundTint="@color/call_action_foreground"
android:backgroundTint="@color/destructive"
android:layout_width="@dimen/large_button_height"
android:layout_height="@dimen/large_button_height"
android:layout_marginBottom="@dimen/very_large_spacing"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintHorizontal_bias="0.25"
android:id="@+id/declineCallButton"/>
<androidx.constraintlayout.widget.Group
android:id="@+id/controlGroup"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="enableCameraButton,endCallButton,switchCameraButton,speakerPhoneButton,microphoneButton"
/>
<androidx.constraintlayout.widget.Group
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/incomingControlGroup"
app:constraint_referenced_ids="acceptCallButton,declineCallButton"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/controlGroupBarrier"
app:barrierDirection="top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:constraint_referenced_ids="switchCameraButton,enableCameraButton,microphoneButton,speakerPhoneButton,acceptCallButton,declineCallButton"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingLeft="@dimen/large_spacing"
android:paddingRight="@dimen/large_spacing"
android:paddingBottom="@dimen/large_spacing"
app:behavior_hideable="true"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/profilePictureView"
android:layout_width="@dimen/large_profile_picture_size"
android:layout_height="@dimen/large_profile_picture_size"
android:layout_marginTop="@dimen/large_spacing"/>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/medium_spacing"
android:gravity="center">
<TextView
android:id="@+id/nameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/small_spacing"
android:layout_marginEnd="@dimen/small_spacing"
android:textSize="@dimen/large_font_size"
android:textStyle="bold"
android:textColor="@color/text"
android:textAlignment="center"
tools:text="Incoming call from... big name here of a user" />
</RelativeLayout>
<LinearLayout
android:layout_marginTop="@dimen/medium_spacing"
android:paddingVertical="@dimen/medium_spacing"
android:layout_gravity="center"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
style="@style/Widget.Session.Button.Common.ProminentOutline"
android:layout_marginHorizontal="@dimen/small_spacing"
android:id="@+id/acceptButton"
android:layout_width="wrap_content"
android:layout_height="@dimen/medium_button_height"
android:gravity="center"
android:paddingLeft="@dimen/large_spacing"
android:paddingRight="@dimen/large_spacing"
android:text="Accept" />
<TextView
style="@style/Widget.Session.Button.Common.ProminentFilled"
android:backgroundTint="@color/destructive"
android:layout_marginHorizontal="@dimen/small_spacing"
android:id="@+id/declineButton"
android:layout_width="wrap_content"
android:layout_height="@dimen/medium_button_height"
android:gravity="center"
android:paddingLeft="@dimen/large_spacing"
android:paddingRight="@dimen/large_spacing"
android:text="Decline" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:title="@string/conversation_context__menu_call"
android:icon="@drawable/ic_baseline_call_24"
app:showAsAction="always"
android:id="@+id/menu_call"/>
</menu>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -40,6 +40,12 @@
<color name="conversation_pinned_background">#404040</color>
<color name="conversation_pinned_icon">#B3B3B3</color>
<color name="call_action_button">#DD353535</color>
<color name="call_action_button_highlighted">#FFFFFF</color>
<color name="call_action_foreground">#D8D8D8</color>
<color name="call_action_foreground_highlighted">#171717</color>
<color name="call_background">#171717</color>
<array name="profile_picture_placeholder_colors">
<item>#5ff8b0</item>
<item>#26cdb9</item>

View file

@ -12,6 +12,7 @@
<!-- Element Sizes -->
<dimen name="small_button_height">34dp</dimen>
<dimen name="medium_button_height">38dp</dimen>
<dimen name="large_button_height">54dp</dimen>
<dimen name="medium_button_corner_radius">22dp</dimen>
<dimen name="accent_line_thickness">4dp</dimen>
<dimen name="very_small_profile_picture_size">26dp</dimen>

View file

@ -5,7 +5,7 @@
<string name="no">No</string>
<string name="delete">Delete</string>
<string name="ban">Ban</string>
<string name="please_wait">Please wait...</string>
<string name="please_wait">Please wait</string>
<string name="save">Save</string>
<string name="note_to_self">Note to Self</string>
<string name="version_s">Version %s</string>
@ -99,6 +99,9 @@
<string name="ConversationActivity_search_position">%1$d of %2$d</string>
<string name="ConversationActivity_call_title">Call Permissions Required</string>
<string name="ConversationActivity_call_prompt">You can enable the \'Voice and video calls\' permission in the Privacy Settings.</string>
<!-- ConversationFragment -->
<plurals name="ConversationFragment_delete_selected_messages">
<item quantity="one">Delete selected message?</item>
@ -560,6 +563,7 @@
<string name="conversation_context__menu_ban_and_delete_all">Ban and delete all</string>
<string name="conversation_context__menu_resend_message">Resend message</string>
<string name="conversation_context__menu_reply_to_message">Reply to message</string>
<string name="conversation_context__menu_call">Call</string>
<!-- conversation_context_image -->
<string name="conversation_context_image__save_attachment">Save attachment</string>
@ -899,5 +903,26 @@
<string name="NewConversationButton_ClosedGroupTooltip">Closed Group</string>
<string name="NewConversationButton_OpenGroupTooltip">Open Group</string>
<string name="message_requests_notification">You have a new message request</string>
<string name="CallNotificationBuilder_connecting">Connecting…</string>
<string name="NotificationBarManager__incoming_signal_call">Incoming call</string>
<string name="NotificationBarManager__deny_call">Deny call</string>
<string name="NotificationBarManager__answer_call">Answer call</string>
<string name="NotificationBarManager_call_in_progress">Call in progress</string>
<string name="NotificationBarManager__cancel_call">Cancel call</string>
<string name="NotificationBarManager__establishing_signal_call">Establishing call</string>
<string name="NotificationBarManager__end_call">End call</string>
<string name="accept_call">Accept Call</string>
<string name="decline_call">Decline call</string>
<string name="preferences__voice_video_calls">Voice and video calls</string>
<string name="preferences__allow_access_voice_video">Allow access to accept voice and video calls from other users</string>
<string name="dialog_voice_video_title">Voice / video calls</string>
<string name="dialog_voice_video_message">The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user</string>
<string name="CallNotificationBuilder_first_call_title">Call Missed</string>
<string name="CallNotificationBuilder_first_call_message">You missed a call because you need to enable the \'Voice and video calls\' permission in the Privacy Settings.</string>
<string name="WebRtcCallActivity_Session_Call">Session Call</string>
<string name="WebRtcCallActivity_Reconnecting">Reconnecting…</string>
<string name="CallNotificationBuilder_system_notification_title">Notifications</string>
<string name="CallNotificationBuilder_system_notification_message">Having notifications disabled will prevent you from receiving calls, go to Session notification settings?</string>
<string name="dismiss">Dismiss</string>
</resources>

View file

@ -83,6 +83,12 @@
<item name="android:drawableTint" tools:ignore="NewApi">@color/accent</item>
</style>
<style name="Widget.Session.Button.Common.ProminentOutline.Accent">
<item name="android:background">@drawable/prominent_outline_button_medium_background_accent</item>
<item name="android:textColor">@color/accent</item>
<item name="android:drawableTint" tools:ignore="NewApi">@color/accent</item>
</style>
<style name="Widget.Session.Button.Common.UnimportantFilled">
<item name="android:background">@drawable/unimportant_filled_button_medium_background</item>
<item name="android:textColor">?android:textColorPrimary</item>
@ -95,6 +101,13 @@
<item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item>
</style>
<style name="Widget.Session.Button.Common.UnimportantDestructive">
<item name="android:background">@drawable/unimportant_outline_button_medium_background</item>
<item name="android:textColor">@color/destructive</item>
<item name="android:backgroundTint" tools:ignore="NewApi">@color/destructive</item>
<item name="android:drawableTint" tools:ignore="NewApi">@color/destructive</item>
</style>
<style name="Widget.Session.Button.Common.DestructiveOutline">
<item name="android:background">@drawable/destructive_outline_button_medium_background</item>
<item name="android:textColor">@color/destructive</item>

View file

@ -145,6 +145,13 @@
<!-- leave empty to allow overriding -->
</style>
<style name="Theme.Session.CallActivity" parent="Theme.Session.ForceDark">
<!-- in case we want to add customisation like no title -->
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:statusBarColor">@color/black</item>
</style>
<style name="Theme.Session.BottomSheet" parent="@style/Theme.AppCompat.DayNight.Dialog">
<item name="colorControlNormal">?android:textColorPrimary</item>
<item name="android:textColorPrimary">@color/text</item>

View file

@ -84,6 +84,11 @@
<!-- <Preference android:key="preference_category_blocked"
android:title="@string/preferences_app_protection__blocked_contacts" /> -->
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="false"
android:key="pref_call_notifications_enabled"
android:title="@string/preferences__voice_video_calls"
android:summary="@string/preferences__allow_access_voice_video"/>
</PreferenceCategory>
<!-- <PreferenceCategory android:layout="@layout/preference_divider"/>

View file

@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.calls
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mockito.MockedStatic
import org.mockito.Mockito.any
import org.mockito.Mockito.mockStatic
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.webrtc.data.Event
import org.thoughtcrime.securesms.webrtc.data.State
class CallStateMachineTests {
private lateinit var stateProcessor: TestStateProcessor
lateinit var mock: MockedStatic<Log>
@Before
fun setup() {
stateProcessor = TestStateProcessor(State.Idle)
mock = mockStatic(Log::class.java).apply {
`when`<Unit> { Log.e(any(), any(), any()) }.then { invocation ->
val msg = invocation.getArgument<Any>(1)
println(msg)
}
`when`<Unit> { Log.i(any(), any(), any()) }.then { invocation ->
val msg = invocation.getArgument<Any>(1)
println(msg)
}
}
}
@After
fun teardown() {
mock.close()
}
@Test
fun `should transition to full connection from remote offer`() {
val executions = listOf(
Event.ReceivePreOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.Connect
)
executions.forEach { event ->
stateProcessor.processEvent(event)
}
assertEquals(stateProcessor.transitions, executions.size)
assertEquals(stateProcessor.currentState, State.Connected)
}
@Test
fun `should transition to full connection from local offer`() {
val executions = listOf(
Event.ReceivePreOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.Connect
)
executions.forEach { event ->
stateProcessor.processEvent(event)
}
assertEquals(stateProcessor.transitions, executions.size)
assertEquals(stateProcessor.currentState, State.Connected)
}
@Test
fun `should not transition to connected from idle`() {
val executions = listOf(
Event.Connect
)
executions.forEach { event ->
stateProcessor.processEvent(event)
}
assertEquals(stateProcessor.transitions, 0)
assertEquals(stateProcessor.currentState, State.Idle)
}
@Test
fun `should not transition to connecting from local and remote offers`() {
val executions = listOf(
Event.SendPreOffer,
Event.SendOffer,
Event.ReceivePreOffer,
Event.ReceiveOffer
)
val validTransitions = 2
executions.forEach { event ->
stateProcessor.processEvent(event)
}
assertEquals(stateProcessor.transitions, validTransitions)
assertEquals(stateProcessor.currentState, State.LocalRing)
}
@Test
fun `cannot answer in local ring`() {
val executions = listOf(
Event.SendPreOffer,
Event.SendOffer,
Event.SendAnswer
)
val validTransitions = 2
executions.forEach { event ->
stateProcessor.processEvent(event)
}
assertEquals(stateProcessor.transitions, validTransitions)
assertEquals(stateProcessor.currentState, State.LocalRing)
}
@Test
fun `test full state cycles`() {
val executions = listOf(
Event.ReceivePreOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.Connect,
Event.Hangup,
Event.Cleanup,
Event.SendPreOffer,
Event.SendOffer,
Event.ReceiveAnswer,
Event.Connect,
Event.IceDisconnect,
Event.NetworkReconnect,
Event.ReceiveAnswer,
Event.Connect,
Event.Hangup,
Event.Cleanup,
Event.ReceivePreOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.Connect,
Event.IceDisconnect,
Event.PrepareForNewOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.Connect,
Event.Hangup,
Event.Cleanup,
Event.ReceivePreOffer,
Event.ReceiveOffer,
Event.SendAnswer,
Event.IceFailed,
Event.Cleanup,
Event.ReceivePreOffer,
Event.DeclineCall,
Event.Cleanup
)
executions.forEach { event -> stateProcessor.processEvent(event) }
assertEquals(State.Idle, stateProcessor.currentState)
assertEquals(executions.size, stateProcessor.transitions)
}
}

View file

@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.calls
import org.thoughtcrime.securesms.webrtc.data.Event
import org.thoughtcrime.securesms.webrtc.data.State
import org.thoughtcrime.securesms.webrtc.data.StateProcessor
class TestStateProcessor(initial: State): StateProcessor(initial) {
private var _transitions = 0
val transitions get() = _transitions
override fun processEvent(event: Event, sideEffect: () -> Unit): Boolean {
val didExecute = super.processEvent(event, sideEffect)
if (didExecute) _transitions++
return didExecute
}
}

View file

@ -1,20 +1,22 @@
package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import android.view.View;
import android.view.ViewGroup;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.content.Context;
import android.database.Cursor;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import org.junit.Before;
import org.junit.Test;
public class CursorRecyclerViewAdapterTest {
private CursorRecyclerViewAdapter adapter;
private Context context;

View file

@ -0,0 +1,7 @@
package org.session.libsession.database
interface CallDataProvider {
// answer/offer for call by UUID
// recipient info for call by UUID
}

View file

@ -2,6 +2,7 @@ package org.session.libsession.database
import android.content.Context
import android.net.Uri
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.Job
@ -159,4 +160,6 @@ interface StorageProtocol {
fun insertMessageRequestResponse(response: MessageRequestResponse)
fun setRecipientApproved(recipient: Recipient, approved: Boolean)
fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean)
fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long)
fun conversationHasOutgoing(userPublicKey: String): Boolean
}

View file

@ -0,0 +1,8 @@
package org.session.libsession.messaging.calls
enum class CallMessageType {
CALL_MISSED,
CALL_INCOMING,
CALL_OUTGOING,
CALL_FIRST_MISSED,
}

View file

@ -31,6 +31,8 @@ class BatchMessageReceiveJob(
const val TAG = "BatchMessageReceiveJob"
const val KEY = "BatchMessageReceiveJob"
const val BATCH_DEFAULT_NUMBER = 50
// Keys used for database storage
private val NUM_MESSAGES_KEY = "numMessages"
private val DATA_KEY = "data"

Some files were not shown because too many files have changed in this diff Show more