mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-23 10:05:15 +00:00
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:
parent
04dfe99517
commit
e1b6bb7e56
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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();
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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?>() {
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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)
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -93,7 +93,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
|
||||
}
|
||||
|
||||
if (privacy.isDisplayContact() && sender.getContactUri() != null) {
|
||||
addPerson(sender.getContactUri().toString());
|
||||
// addPerson(sender.getContactUri().toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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>?) {}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.webrtc
|
||||
|
||||
enum class AudioEvent {
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
//
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.webrtc
|
||||
|
||||
class PeerConnectionException: Exception {
|
||||
constructor(error: String?): super(error)
|
||||
constructor(throwable: Throwable): super(throwable)
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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>)
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
@ -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) }
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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 |
BIN
app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp
Normal file
BIN
app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 404 B |
BIN
app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp
Normal file
BIN
app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 966 B |
13
app/src/main/res/drawable/call_controls_background.xml
Normal file
13
app/src/main/res/drawable/call_controls_background.xml
Normal 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>
|
@ -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"/>
|
||||
|
10
app/src/main/res/drawable/ic_baseline_call_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_call_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_baseline_call_end_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_call_end_24.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_baseline_call_made_24.xml
Normal file
11
app/src/main/res/drawable/ic_baseline_call_made_24.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_baseline_call_missed_24.xml
Normal file
11
app/src/main/res/drawable/ic_baseline_call_missed_24.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_baseline_call_received_24.xml
Normal file
11
app/src/main/res/drawable/ic_baseline_call_received_24.xml
Normal 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>
|
@ -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>
|
10
app/src/main/res/drawable/ic_baseline_mic_off_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_mic_off_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_baseline_videocam_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_videocam_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_baseline_videocam_off_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_videocam_off_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_baseline_volume_mute_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_volume_mute_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_baseline_volume_up_24.xml
Normal file
10
app/src/main/res/drawable/ic_baseline_volume_up_24.xml
Normal 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>
|
12
app/src/main/res/drawable/ic_incoming_call.xml
Normal file
12
app/src/main/res/drawable/ic_incoming_call.xml
Normal 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>
|
12
app/src/main/res/drawable/ic_missed_call.xml
Normal file
12
app/src/main/res/drawable/ic_missed_call.xml
Normal 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>
|
12
app/src/main/res/drawable/ic_outgoing_call.xml
Normal file
12
app/src/main/res/drawable/ic_outgoing_call.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_outline_mic_none_24.xml
Normal file
10
app/src/main/res/drawable/ic_outline_mic_none_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_outline_mic_off_24.xml
Normal file
10
app/src/main/res/drawable/ic_outline_mic_off_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_outline_videocam_24.xml
Normal file
10
app/src/main/res/drawable/ic_outline_videocam_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_outline_videocam_off_24.xml
Normal file
10
app/src/main/res/drawable/ic_outline_videocam_off_24.xml
Normal 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>
|
@ -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>
|
266
app/src/main/res/layout/activity_webrtc.xml
Normal file
266
app/src/main/res/layout/activity_webrtc.xml
Normal 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>
|
77
app/src/main/res/layout/fragment_call_bottom_sheet.xml
Normal file
77
app/src/main/res/layout/fragment_call_bottom_sheet.xml
Normal 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>
|
9
app/src/main/res/menu/menu_conversation_call.xml
Normal file
9
app/src/main/res/menu/menu_conversation_call.xml
Normal 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>
|
BIN
app/src/main/res/raw/redphone_busy.mp3
Normal file
BIN
app/src/main/res/raw/redphone_busy.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/redphone_outring.mp3
Normal file
BIN
app/src/main/res/raw/redphone_outring.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/webrtc_completed.mp3
Normal file
BIN
app/src/main/res/raw/webrtc_completed.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/webrtc_disconnected.mp3
Normal file
BIN
app/src/main/res/raw/webrtc_disconnected.mp3
Normal file
Binary file not shown.
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"/>
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -0,0 +1,7 @@
|
||||
package org.session.libsession.database
|
||||
|
||||
interface CallDataProvider {
|
||||
// answer/offer for call by UUID
|
||||
// recipient info for call by UUID
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
package org.session.libsession.messaging.calls
|
||||
|
||||
enum class CallMessageType {
|
||||
CALL_MISSED,
|
||||
CALL_INCOMING,
|
||||
CALL_OUTGOING,
|
||||
CALL_FIRST_MISSED,
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user