feat: add call related permissions and more network handover tests

This commit is contained in:
Harris
2021-11-17 12:51:15 +11:00
parent bf74483b9f
commit 98a50cbf69
12 changed files with 118 additions and 42 deletions

View File

@@ -1,21 +1,27 @@
package org.thoughtcrime.securesms.preferences; package org.thoughtcrime.securesms.preferences;
import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog;
import android.app.KeyguardManager; import android.app.KeyguardManager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.preference.Preference; import androidx.preference.Preference;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import kotlin.jvm.functions.Function1;
import mobi.upod.timedurationpicker.TimeDurationPickerDialog; import mobi.upod.timedurationpicker.TimeDurationPickerDialog;
import network.loki.messenger.R; import network.loki.messenger.R;
@@ -36,10 +42,22 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener());
this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener());
this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener()); this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener());
this.findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED).setOnPreferenceChangeListener(new CallToggleListener(this, this::setCall));
initializeVisibility(); initializeVisibility();
} }
private Void setCall(boolean isEnabled) {
((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)).setChecked(isEnabled);
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 @Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_app_protection); addPreferencesFromResource(R.xml.preferences_app_protection);
@@ -136,4 +154,52 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
return true; return true;
} }
} }
private class CallToggleListener implements Preference.OnPreferenceChangeListener {
private final Fragment context;
private final Function1<Boolean, Void> setCallback;
private CallToggleListener(Fragment context, Function1<Boolean,Void> setCallback) {
this.context = context;
this.setCallback = setCallback;
}
private void requestMicrophonePermission() {
Permissions.with(context)
.request(Manifest.permission.RECORD_AUDIO)
.onAllGranted(() -> {
TextSecurePreferences.setBooleanPreference(context.requireContext(), TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, true);
setCallback.invoke(true);
})
.onAnyDenied(() -> setCallback.invoke(false))
.execute();
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean val = (boolean) newValue;
if (val) {
// check if we've shown the info dialog and check for microphone permissions
if (TextSecurePreferences.setShownCallWarning(context.requireContext())) {
new AlertDialog.Builder(context.requireContext())
.setTitle(R.string.dialog_voice_video_title)
.setMessage(R.string.dialog_voice_video_message)
.setPositiveButton(R.string.dialog_link_preview_enable_button_title, (d, w) -> {
requestMicrophonePermission();
})
.setNegativeButton(R.string.cancel, (d, w) -> {
})
.show();
} else {
requestMicrophonePermission();
}
return false;
} else {
return true;
}
}
}
} }

View File

@@ -6,13 +6,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.net.ConnectivityManager
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.ResultReceiver import android.os.ResultReceiver
import android.telephony.PhoneStateListener import android.telephony.PhoneStateListener
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.FutureTaskListener import org.session.libsession.utilities.FutureTaskListener
@@ -32,7 +31,6 @@ import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger
import org.thoughtcrime.securesms.webrtc.locks.LockManager import org.thoughtcrime.securesms.webrtc.locks.LockManager
import org.webrtc.* import org.webrtc.*
import org.webrtc.PeerConnection.IceConnectionState.* import org.webrtc.PeerConnection.IceConnectionState.*
import java.lang.AssertionError
import java.util.* import java.util.*
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -228,10 +226,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
networkChangedReceiver = NetworkChangeReceiver { available -> networkChangedReceiver = NetworkChangeReceiver { available ->
networkChange(available) networkChange(available)
} }
registerReceiver(networkChangedReceiver, IntentFilter().apply { LocalBroadcastManager.getInstance(this).registerReceiver(networkChangedReceiver!!, IntentFilter("pathsBuilt"))
addAction("android.net.conn.CONNECTIVITY_CHANGE")
addAction("android.net.wifi.WIFI_STATE_CHANGED")
})
} }
private fun registerUncaughtExceptionHandler() { private fun registerUncaughtExceptionHandler() {
@@ -298,7 +293,6 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return
val callId = getCallId(intent) val callId = getCallId(intent)
val recipient = getRemoteRecipient(intent) val recipient = getRemoteRecipient(intent)
callManager.clearPendingIceUpdates()
callManager.onNewOffer(offer, callId, recipient) callManager.onNewOffer(offer, callId, recipient)
} }
@@ -338,7 +332,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING)
setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient) setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient)
// TODO: DatabaseComponent.get(this).insertOutgoingCall(callManager.recipient!!.address) // TODO: DatabaseComponent.get(this).insertOutgoingCall(callManager.recipient!!.address)
timeoutExecutor.schedule(TimeoutRunnable(callId, this), 5, TimeUnit.MINUTES) timeoutExecutor.schedule(TimeoutRunnable(callId, this), 2, TimeUnit.MINUTES)
val expectedState = callManager.currentConnectionState val expectedState = callManager.currentConnectionState
val expectedCallId = callManager.callId val expectedCallId = callManager.callId
@@ -533,7 +527,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
if (callId == getCallId(intent) && callState != STATE_CONNECTED) { if (callId == getCallId(intent) && callState != STATE_CONNECTED) {
Log.w(TAG, "Timing out call: $callId") Log.w(TAG, "Timing out call: $callId")
callManager.postViewModelState(CallViewModel.State.CALL_DISCONNECTED) handleLocalHangup(intent)
} }
} }
@@ -571,7 +565,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
unregisterReceiver(receiver) unregisterReceiver(receiver)
} }
networkChangedReceiver?.let { receiver -> networkChangedReceiver?.let { receiver ->
unregisterReceiver(receiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
} }
networkChangedReceiver = null networkChangedReceiver = null
callReceiver = null callReceiver = null
@@ -584,7 +578,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
} }
fun networkChange(networkAvailable: Boolean) { fun networkChange(networkAvailable: Boolean) {
if (networkAvailable && callManager.currentConnectionState in arrayOf(STATE_CONNECTED, STATE_ANSWERING, STATE_DIALING)) { if (!callManager.isReestablishing && callManager.currentConnectionState in arrayOf(STATE_CONNECTED)) {
callManager.networkReestablished() callManager.networkReestablished()
} }
} }
@@ -676,6 +670,7 @@ class WebRtcCallService: Service(), PeerConnection.Observer {
startService(intent) startService(intent)
} }
Log.d(TAG, "onIceConnectionChange: $newState")
} }
override fun onIceConnectionReceivingChange(p0: Boolean) {} override fun onIceConnectionReceivingChange(p0: Boolean) {}

View File

@@ -10,7 +10,6 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.task
import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.CallMessage
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Debouncer
@@ -116,12 +115,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat): PeerConne
var recipient: Recipient? = null var recipient: Recipient? = null
set(value) { set(value) {
field = value field = value
_recipientEvents.value = StateEvent.RecipientUpdate(value) _recipientEvents.value = RecipientUpdate(value)
} }
var isReestablishing: Boolean = false var isReestablishing: Boolean = false
fun getCurrentCallState(): Pair<CallState, UUID?> = currentConnectionState to callId
private var peerConnection: PeerConnectionWrapper? = null private var peerConnection: PeerConnectionWrapper? = null
private var dataChannel: DataChannel? = null private var dataChannel: DataChannel? = null
@@ -537,7 +534,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat): PeerConne
} }
fun handleResponseMessage(recipient: Recipient, callId: UUID, answer: SessionDescription) { fun handleResponseMessage(recipient: Recipient, callId: UUID, answer: SessionDescription) {
if (currentConnectionState != CallState.STATE_DIALING || recipient != this.recipient || callId != this.callId) { if (currentConnectionState !in arrayOf(CallState.STATE_DIALING, CallState.STATE_CONNECTED) || recipient != this.recipient || callId != this.callId) {
Log.w(TAG,"Got answer for recipient and call ID we're not currently dialing") Log.w(TAG,"Got answer for recipient and call ID we're not currently dialing")
return return
} }
@@ -603,14 +600,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat): PeerConne
val recipient = recipient ?: return val recipient = recipient ?: return
if (isReestablishing) return if (isReestablishing) return
isReestablishing = true
val offer = connection.createOffer(MediaConstraints().apply { val offer = connection.createOffer(MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
}) })
connection.setLocalDescription(offer)
isReestablishing = true MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address).success {
isReestablishing = false
MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) }
} }
@Serializable @Serializable

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.CallMessage
import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.* import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.*
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
@@ -22,6 +23,10 @@ class CallMessageProcessor(private val context: Context, lifecycle: Lifecycle) {
while (isActive) { while (isActive) {
val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive() val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive()
Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED") Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED")
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
Log.d("Loki","Dropping call message if call notifications disabled")
// TODO: maybe insert a message here saying you missed a call due to permissions
}
when (nextMessage.type) { when (nextMessage.type) {
OFFER -> incomingCall(nextMessage) OFFER -> incomingCall(nextMessage)
ANSWER -> incomingAnswer(nextMessage) ANSWER -> incomingAnswer(nextMessage)

View File

@@ -4,10 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import org.session.libsignal.utilities.Log
class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit): BroadcastReceiver() { class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit): BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d("Loki", intent.toString())
onNetworkChangedCallback(context.isConnected()) onNetworkChangedCallback(context.isConnected())
} }

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.webrtc package org.thoughtcrime.securesms.webrtc
import android.content.Context import android.content.Context
import kotlinx.coroutines.runBlocking
import org.session.libsignal.utilities.SettableFuture import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.webrtc.video.Camera import org.thoughtcrime.securesms.webrtc.video.Camera
import org.thoughtcrime.securesms.webrtc.video.CameraEventListener import org.thoughtcrime.securesms.webrtc.video.CameraEventListener
@@ -32,7 +31,6 @@ class PeerConnectionWrapper(context: Context,
val iceServers = listOf(turn) val iceServers = listOf(turn)
val constraints = MediaConstraints().apply { val constraints = MediaConstraints().apply {
optional.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
optional.add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")) optional.add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"))
} }
val audioConstraints = MediaConstraints().apply { val audioConstraints = MediaConstraints().apply {

View File

@@ -5,11 +5,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.telephony.PhoneStateListener import android.telephony.PhoneStateListener
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.webrtc.locks.LockManager import org.thoughtcrime.securesms.webrtc.locks.LockManager
import javax.inject.Inject
class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): PhoneStateListener() { class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): PhoneStateListener() {
@@ -27,17 +25,6 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit):
} }
} }
//@AndroidEntryPoint
class NetworkReceiver: BroadcastReceiver() {
// @Inject
// lateinit var callManager: CallManager
override fun onReceive(context: Context, intent: Intent) {
TODO("Not yet implemented")
}
}
class PowerButtonReceiver : BroadcastReceiver() { class PowerButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_SCREEN_OFF == intent.action) { if (Intent.ACTION_SCREEN_OFF == intent.action) {

View File

@@ -913,5 +913,9 @@
<string name="NotificationBarManager__end_call">End call</string> <string name="NotificationBarManager__end_call">End call</string>
<string name="accept_call">Accept Call</string> <string name="accept_call">Accept Call</string>
<string name="decline_call">Decline 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>
</resources> </resources>

View File

@@ -84,6 +84,11 @@
<!-- <Preference android:key="preference_category_blocked" <!-- <Preference android:key="preference_category_blocked"
android:title="@string/preferences_app_protection__blocked_contacts" /> --> 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>
<!-- <PreferenceCategory android:layout="@layout/preference_divider"/> <!-- <PreferenceCategory android:layout="@layout/preference_divider"/>

View File

@@ -8,10 +8,7 @@ import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.jobs.NotifyPNServerJob import org.session.libsession.messaging.jobs.NotifyPNServerJob
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.messages.control.*
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.* import org.session.libsession.messaging.messages.visible.*
import org.session.libsession.messaging.open_groups.* import org.session.libsession.messaging.open_groups.*
import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.MessageWrapper
@@ -165,7 +162,7 @@ object MessageSender {
val hash = it["hash"] as? String val hash = it["hash"] as? String
message.serverHash = hash message.serverHash = hash
handleSuccessfulMessageSend(message, destination, isSyncMessage) handleSuccessfulMessageSend(message, destination, isSyncMessage)
var shouldNotify = ((message is VisibleMessage || message is UnsendRequest) && !isSyncMessage) var shouldNotify = ((message is VisibleMessage || message is UnsendRequest || message is CallMessage) && !isSyncMessage)
/* /*
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) { if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) {
shouldNotify = true shouldNotify = true

View File

@@ -89,7 +89,8 @@ object TextSecurePreferences {
const val CONFIGURATION_SYNCED = "pref_configuration_synced" const val CONFIGURATION_SYNCED = "pref_configuration_synced"
private const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time" private const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
private const val LAST_OPEN_DATE = "pref_last_open_date" private const val LAST_OPEN_DATE = "pref_last_open_date"
private const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled" const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled"
private const val SHOWN_CALL_WARNING = "pref_shown_call_warning"
@JvmStatic @JvmStatic
fun getLastConfigurationSyncTime(context: Context): Long { fun getLastConfigurationSyncTime(context: Context): Long {
@@ -742,4 +743,21 @@ object TextSecurePreferences {
fun isCallNotificationsEnabled(context: Context): Boolean { fun isCallNotificationsEnabled(context: Context): Boolean {
return getBooleanPreference(context, CALL_NOTIFICATIONS_ENABLED, false) return getBooleanPreference(context, CALL_NOTIFICATIONS_ENABLED, false)
} }
/**
* Set the SHOWN_CALL_WARNING preference to `true`
* Return `true` if the value did update (it was previously unset)
*/
@JvmStatic
fun setShownCallWarning(context: Context) : Boolean {
val previousValue = getBooleanPreference(context, SHOWN_CALL_WARNING, false)
if (previousValue) {
return false
}
val setValue = true
setBooleanPreference(context, SHOWN_CALL_WARNING, setValue)
return previousValue != setValue
}
} }

View File

@@ -4,7 +4,7 @@ import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred import nl.komponents.kovenant.deferred
import java.util.* import java.util.*
fun <V, T : Promise<V, Exception>> retryIfNeeded(maxRetryCount: Int, retryInterval: Long = 1 * 1000, body: () -> T): Promise<V, Exception> { fun <V, T : Promise<V, Exception>> retryIfNeeded(maxRetryCount: Int, retryInterval: Long = 1000L, body: () -> T): Promise<V, Exception> {
var retryCount = 0 var retryCount = 0
val deferred = deferred<V, Exception>() val deferred = deferred<V, Exception>()
val thread = Thread.currentThread() val thread = Thread.currentThread()