package ch.threema.app.voip.services;

import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
import static ch.threema.app.notifications.NotificationIDs.IN_CALL_NOTIFICATION_ID;
import static ch.threema.base.HasDevFeaturesKt.HAS_DEV_FEATURES;
import static ch.threema.app.voip.services.VideoContext.CAMERA_BACK;
import static ch.threema.app.voip.services.VideoContext.CAMERA_FRONT;
import static ch.threema.app.voip.services.VoipStateService.VIDEO_RENDER_FLAG_NONE;

import android.Manifest;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.AssetFileDescriptor;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.SystemClock;
import android.telephony.PhoneStateListener;
import android.telephony.TelephonyManager;
import android.widget.Toast;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.UiThread;
import androidx.annotation.WorkerThread;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
import androidx.core.app.ServiceCompat;
import androidx.core.content.ContextCompat;
import androidx.core.util.Pair;
import androidx.lifecycle.LifecycleService;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.preference.PreferenceManager;

import org.koin.java.KoinJavaComponent;
import org.slf4j.Logger;
import org.webrtc.CameraVideoCapturer;
import org.webrtc.IceCandidate;
import org.webrtc.PeerConnection;
import org.webrtc.RTCStatsCollectorCallback;
import org.webrtc.RTCStatsReport;
import org.webrtc.SessionDescription;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoSink;

import java.io.IOException;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import ch.threema.annotation.SameThread;
import ch.threema.app.BuildConfig;
import ch.threema.app.R;
import ch.threema.app.apptaskexecutor.AppTaskExecutor;
import ch.threema.app.di.DependencyContainer;
import ch.threema.app.eventbus.GlobalEventBuses;
import ch.threema.app.eventbus.events.VoipCallEvent;
import ch.threema.app.listeners.VoipCallListener;
import ch.threema.app.managers.ListenerManager;
import ch.threema.app.notifications.NotificationChannels;
import ch.threema.app.notifications.NotificationGroups;
import ch.threema.app.notifications.NotificationRequestCodes;
import ch.threema.app.preference.service.PreferenceService;
import ch.threema.app.ui.SingleToast;
import ch.threema.app.utils.AudioDevice;
import ch.threema.app.utils.CloseableLock;
import ch.threema.app.utils.CloseableReadWriteLock;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.utils.MediaPlayerStateWrapper;
import ch.threema.app.utils.RuntimeUtil;
import ch.threema.app.voip.CallStateSnapshot;
import ch.threema.app.voip.PeerConnectionClient;
import ch.threema.app.voip.VoipAudioManager;
import ch.threema.app.voip.activities.CallActivity;
import ch.threema.app.voip.listeners.VoipAudioManagerListener;
import ch.threema.app.voip.listeners.VoipMessageListener;
import ch.threema.app.voip.managers.VoipListenerManager;
import ch.threema.app.voip.receivers.IncomingMobileCallReceiver;
import ch.threema.app.voip.receivers.MeteredStatusChangedReceiver;
import ch.threema.app.voip.util.CallIdGenerator;
import ch.threema.app.voip.util.SdpPatcher;
import ch.threema.app.voip.util.SdpUtil;
import ch.threema.app.voip.util.VideoCapturerUtil;
import ch.threema.app.voip.util.VoipSound;
import ch.threema.app.voip.util.VoipStats;
import ch.threema.app.voip.util.VoipUtil;
import ch.threema.app.voip.util.VoipVideoParams;
import ch.threema.base.ThreemaException;

import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
import static ch.threema.common.JavaCompat.areEqual;
import static ch.threema.logging.ErrorReportingKt.logAndReportError;

import ch.threema.data.datatypes.ContactConversationId;
import ch.threema.data.models.ContactModelData;
import ch.threema.domain.models.VerificationLevel;
import ch.threema.domain.protocol.ThreemaFeature;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallAnswerData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallHangupData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallOfferData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallRingingData;
import ch.threema.domain.protocol.csp.messages.voip.VoipICECandidatesData;
import ch.threema.domain.protocol.csp.messages.voip.features.FeatureList;
import ch.threema.domain.protocol.csp.messages.voip.features.VideoFeature;
import ch.threema.data.models.ContactModel;
import ch.threema.protobuf.o2o_call.CaptureState;
import ch.threema.protobuf.o2o_call.Envelope;
import ch.threema.protobuf.o2o_call.O2OCall;
import ch.threema.protobuf.o2o_call.VideoQualityProfile;
import kotlin.Unit;

import java.util.function.Supplier;

import kotlin.Lazy;

/**
 * The service keeping track of VoIP call state and the corresponding WebRTC peer connection.
 */
public class VoipCallService extends LifecycleService implements PeerConnectionClient.Events {
    private static final Logger logger = getThreemaLogger("VoipCallService");

    // Intent extras
    public static final String EXTRA_CALL_ID = "CALL_ID";
    public static final String EXTRA_CONTACT_IDENTITY = "CONTACT_IDENTITY";
    public static final String EXTRA_IS_INITIATOR = "IS_INITIATOR";
    public static final String EXTRA_ACTIVITY_MODE = "ACTIVITY_MODE";
    public static final String EXTRA_CANDIDATES = "CANDIDATES";
    public static final String EXTRA_AUDIO_DEVICE = "AUDIO_DEVICE";
    public static final String EXTRA_START_TIME = "START_TIME";
    public static final String EXTRA_LAUNCH_VIDEO = "LAUNCH_VIDEO";
    public static final String EXTRA_CANCEL_WEAR = "CANCEL_ACTIVITY_ON_WATCH";

    // Broadcast actions
    public static final String ACTION_HANGUP = BuildConfig.APPLICATION_ID + ".HANGUP";
    public static final String ACTION_ICE_CANDIDATES = BuildConfig.APPLICATION_ID + ".ICE_CANDIDATES";
    public static final String ACTION_MUTE_TOGGLE = BuildConfig.APPLICATION_ID + ".MUTE_TOGGLE";
    public static final String ACTION_SET_AUDIO_DEVICE = BuildConfig.APPLICATION_ID + ".SET_AUDIO_DEVICE";
    public static final String ACTION_QUERY_AUDIO_DEVICES = BuildConfig.APPLICATION_ID + ".QUERY_AUDIO_DEVICES";
    public static final String ACTION_QUERY_MIC_ENABLED = BuildConfig.APPLICATION_ID + ".QUERY_MIC_ENABLED";
    public static final String ACTION_ENABLE_DEBUG_INFO = BuildConfig.APPLICATION_ID + ".ENABLE_DEBUG_INFO";
    public static final String ACTION_DISABLE_DEBUG_INFO = BuildConfig.APPLICATION_ID + ".DISABLE_DEBUG_INFO";
    public static final String ACTION_START_CAPTURING = BuildConfig.APPLICATION_ID + ".START_CAPTURING";
    public static final String ACTION_STOP_CAPTURING = BuildConfig.APPLICATION_ID + ".STOP_CAPTURING";
    public static final String ACTION_SWITCH_CAMERA = BuildConfig.APPLICATION_ID + ".SWITCH_CAMERA";

    private static final int FOREGROUND_SERVICE_TYPE =
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? FOREGROUND_SERVICE_TYPE_MICROPHONE : 0;

    // Peer connection
    @Nullable
    private PeerConnectionClient peerConnectionClient = null;

    // Audio
    @Nullable
    private VoipAudioManager audioManager = null;

    // Video
    private boolean videoEnabled = true;
    @NonNull
    final private CloseableReadWriteLock videoQualityNegotiation
        = new CloseableReadWriteLock(new ReentrantReadWriteLock());
    @Nullable
    private VoipVideoParams localVideoQualityProfile;
    @Nullable
    private VoipVideoParams remoteVideoQualityProfile;
    @Nullable
    private VoipVideoParams commonVideoQualityProfile;

    private static boolean isRunning = false;

    private boolean foregroundStarted = false;
    private boolean transportConnected = false;
    private boolean transportWasConnected = false;
    private boolean isError = false;
    private boolean micEnabled = true;
    private boolean uiDebugStatsEnabled = false;
    private volatile boolean didShowDisconnectMessage = false;
    private volatile boolean playHangupSoundOnConnectionClosed = true;

    // The peer contact model
    @Nullable
    private static ContactModel contactModel = null;
    @Nullable
    private static ContactModelData contactModelData = null;

    // Offer SDP
    private SessionDescription offerSessionDescription;

    // Dependencies
    // Using `DependencyContainer` here is acceptable, as calls are explicitly ended when the master key is locked,
    // ensuring that this service is stopped and its dependencies released eventually.
    @NonNull
    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
    @NonNull
    private final Lazy<CallIdGenerator> callIdGeneratorLazy = KoinJavaComponent.inject(CallIdGenerator.class);
    @NonNull
    private final Context appContext = KoinJavaComponent.get(Context.class);
    @NonNull
    private final AppTaskExecutor appTaskExecutor = KoinJavaComponent.get(AppTaskExecutor.class);
    @NonNull
    private final GlobalEventBuses globalEventBuses = KoinJavaComponent.get(GlobalEventBuses.class);

    // Listeners
    private VoipMessageListener voipMessageListener;
    private final PhoneStateListener hangUpRtcOnDeviceCallAnswered = new PSTNCallStateListener();

    // Receivers
    private IncomingMobileCallReceiver incomingMobileCallReceiver;

    // Media players
    @Nullable
    private MediaPlayerStateWrapper mediaPlayer;

    // PeerConnection configuration
    private Boolean useHardwareEC = null;

    // Network configuration
    private @Nullable Boolean networkIsMetered; // Only used for change detection!
    private volatile boolean networkIsRelayed = false;

    // Diagnostics
    private long callStartedTimeMs = 0;
    private static long callStartedRealtimeMs = 0;
    private static final long ACTIVITY_STATS_INTERVAL_MS = 1000;
    private static final long LOG_STATS_INTERVAL_MS_CONNECTING = 2000;
    private static final long LOG_STATS_INTERVAL_MS_CONNECTED = 30000;
    private static final long FRAME_DETECTOR_QUERY_INTERVAL_MS = 750;

    // Timeouts
    private final Timer transportDisconnectedSoundTimer = new Timer();
    private TimerTask transportDisconnectedSoundTimeout;
    private static final int ICE_DISCONNECTED_SOUND_TIMEOUT_MS = 1000;

    private final @NonNull Timer initializationTimeoutTimer = new Timer();
    private @Nullable TimerTask initializationTimeoutTask;
    private static final long INITIALIZATION_TIMEOUT_MS = 60000;

    // Camera handling
    private final AtomicBoolean switchCamInProgress = new AtomicBoolean(false);
    private final Object capturingLock = new Object(); // Lock whenever modifying capturing
    private volatile boolean isCapturing = false; // Always synchronize on capturingLock!

    // Managers
    private NotificationManagerCompat notificationManagerCompat;
    private TelephonyManager telephonyManager;
    private SharedPreferences sharedPreferences;

    // Broadcast receivers
    private MeteredStatusChangedReceiver meteredStatusChangedReceiver;
    private final BroadcastReceiver localBroadcastReceiver = new BroadcastReceiver() {
        @Override
        @UiThread
        public void onReceive(Context context, Intent intent) {
            if (intent != null) {
                final String action = intent.getAction();

                if (action != null) {
                    switch (action) {
                        case ACTION_HANGUP:
                            onCallHangUp(null);
                            break;
                        case ACTION_ICE_CANDIDATES:
                            if (!intent.hasExtra(EXTRA_CALL_ID)) {
                                logger.warn("Received broadcast intent without EXTRA_CALL_ID for action ICE_CANDIDATES");
                            }
                            final long callId = intent.getLongExtra(EXTRA_CALL_ID, 0L);
                            final String contactIdentity = intent.getStringExtra(VoipCallService.EXTRA_CONTACT_IDENTITY);
                            final VoipICECandidatesData candidatesData = (VoipICECandidatesData) intent.getSerializableExtra(EXTRA_CANDIDATES);
                            if (contactIdentity != null && candidatesData != null) {
                                long dataCallId = candidatesData.getCallIdOrDefault(0L);
                                if (callId != dataCallId) {
                                    logger.error("Mismatch between intent call ID ({}) and data call ID ({})", callId, dataCallId);
                                } else {
                                    handleNewCandidate(contactIdentity, candidatesData);
                                }
                            }
                            break;
                        case ACTION_MUTE_TOGGLE:
                            onToggleMic();
                            break;
                        case ACTION_SET_AUDIO_DEVICE:
                            if (intent.hasExtra(EXTRA_AUDIO_DEVICE)) {
                                onToggleAudioDevice((AudioDevice) intent.getSerializableExtra(EXTRA_AUDIO_DEVICE));
                            }
                            break;
                        case ACTION_QUERY_AUDIO_DEVICES:
                            if (audioManager != null) {
                                logger.debug("Requesting audio manager notify");
                                audioManager.requestAudioManagerNotify();
                            } else {
                                logger.error("Cannot request audio manager notify: Audio manager is null");
                            }
                            break;
                        case ACTION_QUERY_MIC_ENABLED:
                            if (audioManager != null) {
                                logger.debug("Requesting mute status notify");
                                audioManager.requestMicEnabledNotify();
                            } else {
                                logger.error("Cannot request mute status notify: Audio manager is null");
                            }
                            break;
                        case ACTION_ENABLE_DEBUG_INFO:
                            enableUIDebugStats(true);
                            break;
                        case ACTION_DISABLE_DEBUG_INFO:
                            enableUIDebugStats(false);
                            break;
                        case ACTION_START_CAPTURING:
                            startCapturing();
                            break;
                        case ACTION_STOP_CAPTURING:
                            stopCapturing();
                            break;
                        case ACTION_SWITCH_CAMERA:
                            switchCamera();
                            break;
                        default:
                            break;
                    }
                }
            }
        }
    };

    // preference change receiver
    SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = (sharedPreferences, key) -> {
        if (getString(R.string.preferences__voip_video_profile).equals(key)) {
            // profile has changed
            this.updateOwnVideoQualityProfile(
                Boolean.TRUE.equals(this.meteredStatusChangedReceiver.getMetered().getValue()),
                this.networkIsRelayed
            );
        }
    };

    //region Stats

    /**
     * The activity stats collector is enabled when long-pressing on the callee name.
     * It then periodically collects and shows debug information on the call screen.
     */
    private final RTCStatsCollectorCallback activityStatsCollector = new RTCStatsCollectorCallback() {
        final private VoipStats.Builder builder = new VoipStats.Builder()
            .withSelectedCandidatePair(true)
            .withTransport(true)
            .withCrypto(true)
            .withRtp(true)
            .withTracks(true)
            .withCodecs(false)
            .withCandidatePairs(VoipStats.CandidatePairVariant.OVERVIEW);
        private @Nullable VoipStats.State previousState;

        @Override
        public void onStatsDelivered(RTCStatsReport report) {
            // Extract stats report
            final VoipStats.Extractor extractor = this.builder.extractor();
            if (peerConnectionClient != null) {
                extractor.withRtpTransceivers(peerConnectionClient.getTransceivers());
            }
            if (this.previousState != null) {
                extractor.comparedTo(this.previousState);
            }
            final VoipStats stats = extractor.extract(report);

            // Determine whether a TURN relay is being used
            final boolean usesRelay = stats.usesRelay();
            RuntimeUtil.runOnWorkerThread(() -> updateNetworkRelayState(usesRelay));

            // Create debug text
            final StringBuilder builder = new StringBuilder();
            stats.addShortRepresentation(builder);
            builder.append(" aec=");
            builder.append(useHardwareEC ? "hw" : "sw");
            try (CloseableLock locked = videoQualityNegotiation.tryRead(50, TimeUnit.MILLISECONDS)) {
                builder.append("\nL=").append(localVideoQualityProfile);
                builder.append("\nR=").append(remoteVideoQualityProfile);
                builder.append("\nC=").append(commonVideoQualityProfile);
            } catch (CloseableReadWriteLock.NotLocked ignored) {
            }

            // Store previous state
            this.previousState = stats.getState();

            // Notify listeners about new debug text
            VoipUtil.sendVoipBroadcast(
                getApplicationContext(),
                CallActivity.ACTION_DEBUG_INFO,
                "TEXT",
                builder.toString()
            );
        }
    };

    /**
     * The debugStatsCollector collects stats periodically and writes them
     * to the debug log.
     */
    private CallStatsCollectorCallback debugStatsCollector = null;

    class CallStatsCollectorCallback implements RTCStatsCollectorCallback {
        final private @NonNull VoipStats.Builder builder;
        private @Nullable VoipStats.State previousState;
        private boolean includeTransceivers = true;

        CallStatsCollectorCallback(@NonNull VoipStats.Builder builder) {
            this.builder = builder;
        }

        @Override
        public void onStatsDelivered(RTCStatsReport report) {
            // Get extracted stats
            final VoipStats.Extractor extractor = this.builder.extractor();
            if (this.includeTransceivers && peerConnectionClient != null) {
                extractor.withRtpTransceivers(peerConnectionClient.getTransceivers());
            }
            if (this.previousState != null) {
                extractor.comparedTo(this.previousState);
            }
            final VoipStats stats = extractor.extract(report);
            final StringBuilder builder = new StringBuilder();
            builder.append("Stats\n");
            stats.addRepresentation(builder);

            // Determine whether a TURN relay is being used
            final boolean usesRelay = stats.usesRelay();
            RuntimeUtil.runOnWorkerThread(() -> updateNetworkRelayState(usesRelay));

            // Update state
            this.previousState = stats.getState();
            // Don't log transceivers in subsequent runs
            this.includeTransceivers = false;

            // Log stats
            logger.info(builder.toString());
        }
    }

    private void updateNetworkRelayState(final boolean networkIsRelayed) {
        boolean changed = this.networkIsRelayed != networkIsRelayed;
        this.networkIsRelayed = networkIsRelayed;
        if (changed) {
            // If relay status changes, update video quality profile
            this.updateOwnVideoQualityProfile(
                Boolean.TRUE.equals(meteredStatusChangedReceiver.getMetered().getValue()),
                networkIsRelayed
            );
        }
    }

    private FrameDetectorCallback frameDetector = null;

    /**
     * This class tracks incoming frames. It can notify the passed in runnables
     * when the call partner starts and stops sending video frames.
     * <p>
     * Instances of this class are not thread safe. The methods may be called from different
     * threads, but not concurrently.
     * <p>
     * Note: Due to frequent changes to the stats API spec[1], this might require
     * changes when updating WebRTC!
     * <p>
     * [1]: https://www.w3.org/TR/webrtc-stats/
     */
    @SameThread
    static class FrameDetectorCallback implements RTCStatsCollectorCallback {
        private @NonNull State state = State.STOPPED;
        private @Nullable Long lastFrameDetectionTimestampMs;
        private long lastFrameCount = 0;

        private final @NonNull Runnable framesStarted;
        private final @NonNull Runnable framesStopped;

        /**
         * If no frames are sent for at least the specified number of milliseconds,
         * consider that a "video frames stopped" event.
         */
        @SuppressWarnings("FieldCanBeLocal")
        private static final long STOP_THRESHOLD_MS = 1000;

        FrameDetectorCallback(@NonNull Runnable framesStarted, @NonNull Runnable framesStopped) {
            this.framesStarted = framesStarted;
            this.framesStopped = framesStopped;
        }

        @Override
        public void onStatsDelivered(RTCStatsReport report) {
            if (!ConfigUtils.isVideoCallsEnabled()) {
                return;
            }

            final long totalFramesReceived = getTotalFramesReceived(report);
            logger.trace("FrameDetectorCallback: Total frames received = {}", totalFramesReceived);

            if (totalFramesReceived > this.lastFrameCount) {
                // Frame count increased
                this.lastFrameCount = totalFramesReceived;
                this.lastFrameDetectionTimestampMs = System.nanoTime() / 1000;
                if (this.state == State.STOPPED) {
                    this.state = State.STARTED;
                    logger.debug("FrameDetectorCallback: Started");
                    this.framesStarted.run();
                }
            } else if (totalFramesReceived == this.lastFrameCount) {
                // Frame count stayed the same
                if (this.state == State.STARTED && this.lastFrameDetectionTimestampMs != null) {
                    final long msElapsed = (System.nanoTime() / 1000) - this.lastFrameDetectionTimestampMs;
                    if (msElapsed > STOP_THRESHOLD_MS) {
                        this.state = State.STOPPED;
                        logger.debug("FrameDetectorCallback: Stopped");
                        this.framesStopped.run();
                    }
                }
            } else {
                // Frame count decreased?!
                logger.warn(
                    "FrameDetectorCallback: Frame count decreased from {} to {}",
                    this.lastFrameCount,
                    totalFramesReceived
                );
                this.lastFrameCount = totalFramesReceived;
            }
        }

        /**
         * Extract the total number of frames received across all incoming video tracks.
         */
        private long getTotalFramesReceived(RTCStatsReport report) {
            return report.getStatsMap().values()
                // Iterate over all entries in the stats map
                .parallelStream()
                // Only consider track stats
                .filter(stats -> "track".equals(stats.getType()))
                // Only consider active remote video sources
                .filter(stats -> {
                    final Map<String, Object> members = stats.getMembers();
                    final Object isRemoteSrc = members.get("remoteSource");
                    final Object hasEnded = members.get("ended");
                    final Object kind = members.get("kind");
                    final Object framesReceived = stats.getMembers().get("framesReceived");
                    final Object trackIdentifier = stats.getMembers().get("trackIdentifier");
                    return isRemoteSrc instanceof Boolean
                        && hasEnded instanceof Boolean
                        && kind instanceof String
                        && framesReceived instanceof Long
                        && trackIdentifier instanceof String
                        && (Boolean) isRemoteSrc
                        && !((Boolean) hasEnded)
                        && kind.equals("video");
                })
                .mapToLong(stats -> {
                    //noinspection ConstantConditions
                    return (Long) stats.getMembers().get("framesReceived");
                })
                .sum();
        }

        public enum State {
            STOPPED,
            STARTED,
        }
    }

    //endregion

    public static boolean isRunning() {
        return isRunning;
    }

    public static long getStartTime() {
        return callStartedRealtimeMs;
    }

    public static String getOtherPartysIdentity() {
        return contactModel != null ? contactModel.getIdentity() : null;
    }

    //region Lifecycle

    @Nullable
    @Override
    public IBinder onBind(@NonNull Intent intent) {
        super.onBind(intent);
        return null;
    }

    @Override
    public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
        super.onStartCommand(intent, flags, startId);
        logger.info("onStartCommand");

        // Start flag, to configure whether and how the service is restarted after being killed
        // by the system. For more details, see https://developer.android.com/reference/android/app/Service.html#constants_1
        //
        // If the VoipCallService is killed due to OOM (and onDestroy is called), the
        // PeerConnectionClient will die as well. Therefore it does not make sense to restart
        // the service without any intent (and thus without context), so we choose START_NOT_STICKY
        // here.
        final int RESTART_BEHAVIOR = START_NOT_STICKY;

        // If the service is started with action HANGUP, then we can send the hangup message
        // and stop ourselves.
        final String action = intent != null ? intent.getAction() : null;
        if (VoipCallService.ACTION_HANGUP.equals(action)) {
            onCallHangUp(null);
            return RESTART_BEHAVIOR;
        }

        // The call is going to be initiated. Therefore any ongoing group call must be stopped
        if (dependencies.getGroupCallManager().hasJoinedCall()) {
            logger.info("Stop ongoing group call in favour of 1:1 call");
            dependencies.getGroupCallManager().abortCurrentCall();
        }

        final String contactIdentity = intent != null ? intent.getStringExtra(EXTRA_CONTACT_IDENTITY) : null;
        if (contactIdentity == null) {
            logger.error("Missing contact identity in intent!");
            return RESTART_BEHAVIOR;
        }

        final VoipICECandidatesData candidatesData =
            (VoipICECandidatesData) intent.getSerializableExtra(EXTRA_CANDIDATES);

        // Determine the Call ID. If the Call ID is set to -1L (NOTE: must be a long, not int),
        // that means "generate a new one".
        if (!intent.hasExtra(EXTRA_CALL_ID)) {
            logger.warn("onStartCommand intent without Call ID");
        }
        long callId = intent.getLongExtra(EXTRA_CALL_ID, 0L);
        if (callId == -1) {
            callId = callIdGeneratorLazy.getValue().generateCallId();
        }

        // If candidates are sent with the intent, then we simply want to add those
        // to the existing peer connection.
        if (candidatesData != null) {
            handleNewCandidate(contactIdentity, candidatesData);
        } else {
            // Otherwise, we handle a new call.
            try {
                handleNewCall(callId, contactIdentity, intent);
            } catch (IllegalStateException e) {
                logger.error("Unable to handle call", e);
            }
        }

        return RESTART_BEHAVIOR;
    }

    @Override
    public void onCreate() {
        logger.info("onCreate");
        super.onCreate();

        isRunning = true;

        this.notificationManagerCompat = NotificationManagerCompat.from(this);

        // Create video context
        logger.debug("Creating video context");
        dependencies.getVoipStateService().createVideoContext();

        // Register intent filters
        IntentFilter filter = new IntentFilter();
        filter.addAction(ACTION_HANGUP);
        filter.addAction(ACTION_ICE_CANDIDATES);
        filter.addAction(ACTION_MUTE_TOGGLE);
        filter.addAction(ACTION_SET_AUDIO_DEVICE);
        filter.addAction(ACTION_QUERY_AUDIO_DEVICES);
        filter.addAction(ACTION_QUERY_MIC_ENABLED);
        filter.addAction(ACTION_ENABLE_DEBUG_INFO);
        filter.addAction(ACTION_DISABLE_DEBUG_INFO);
        filter.addAction(ACTION_START_CAPTURING);
        filter.addAction(ACTION_STOP_CAPTURING);
        filter.addAction(ACTION_SWITCH_CAMERA);

        LocalBroadcastManager.getInstance(this).registerReceiver(localBroadcastReceiver, filter);

        // let lifecycle take care of resource management
        meteredStatusChangedReceiver = new MeteredStatusChangedReceiver(this, this);
        meteredStatusChangedReceiver.getMetered().observe(this, metered -> {
            // the connectivity status has changed - adjust parameters
            logger.info("Metered status changed to {}", metered);
            if (metered == null) {
                return;
            }
            boolean changed = !metered.equals(this.networkIsMetered);
            this.networkIsMetered = metered;
            if (changed && peerConnectionClient != null) {
                this.updateOwnVideoQualityProfile(metered, this.networkIsRelayed);
            }
        });

        telephonyManager = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
        if (telephonyManager != null) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ContextCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
                telephonyManager.listen(hangUpRtcOnDeviceCallAnswered, PhoneStateListener.LISTEN_CALL_STATE);
            }
        }

        if (dependencies.getPreferenceService().isRejectMobileCalls()) {
            incomingMobileCallReceiver = new IncomingMobileCallReceiver();
            registerReceiver(incomingMobileCallReceiver, new IntentFilter("android.intent.action.PHONE_STATE"));
        }

        sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener);
    }

    @Override
    public void onDestroy() {
        logger.info("onDestroy");

        if (localBroadcastReceiver != null) {
            try {
                LocalBroadcastManager.getInstance(this).unregisterReceiver(localBroadcastReceiver);
            } catch (IllegalArgumentException e) {
                // ignore if not registered due to premature destruction
            }
        }

        if (incomingMobileCallReceiver != null) {
            try {
                unregisterReceiver(incomingMobileCallReceiver);
            } catch (IllegalArgumentException e) {
                // ignore if not registered due to premature destruction
            }
        }

        // clear telephony listener
        if (telephonyManager != null) {
            telephonyManager.listen(hangUpRtcOnDeviceCallAnswered, PhoneStateListener.LISTEN_NONE);
        }

        if (sharedPreferences != null) {
            sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
        }

        this.cancelInCallNotification();

        isRunning = false;

        // Clean up resources
        this.cleanup();

        super.onDestroy();
    }

    @Override
    public void onTaskRemoved(Intent rootIntent) {
        logger.trace("onTaskRemoved");
        super.onTaskRemoved(rootIntent);
    }

    //endregion

    @UiThread
    public void onCallHangUp(final @StringRes @Nullable Integer userMessageRes) {
        final VoipStateService voipStateService = dependencies.getVoipStateService();
        final CallStateSnapshot callState = voipStateService.getCallState();
        final Integer callDuration = voipStateService.getCallDuration();
        final Boolean isInitiator = voipStateService.isInitiator();

        logCallInfo(callState.getCallId(), "Hanging up call");

        final ContactModel contactModelSnapshot = contactModel;
        if (callState.isInitializing() || callState.isCalling()) {
            appTaskExecutor.runInAppTask(
                () -> {
                    if (contactModelSnapshot == null) {
                        logCallWarning(callState.getCallId(), "Cannot hangup call as the contact model is null");
                        return Unit.INSTANCE;
                    }

                    voipStateService.sendCallHangupMessage(
                        contactModelSnapshot,
                        callState,
                        callDuration,
                        isInitiator
                    );

                    return Unit.INSTANCE;
                }
            );
        }
        disconnect(userMessageRes);
    }

    /**
     * Handle a new incoming or outgoing call.
     */
    @UiThread
    private void handleNewCall(final long callId, final String contactIdentity, final Intent intent) throws IllegalStateException {
        // Do not initiate a new call if one is still running
        final CallStateSnapshot callState = dependencies.getVoipStateService().getCallState();
        if (callState.isCalling()) {
            logCallInfo(
                callId,
                "Call with ID {} is currently ongoing. Ignoring request to initiate new call.",
                callState.getCallId()
            );
            return;
        }

        // Detect whether we're initiator or responder
        final boolean isInitiator = intent.getBooleanExtra(EXTRA_IS_INITIATOR, false);
        dependencies.getVoipStateService().setInitiator(isInitiator);

        logCallInfo(
            callId,
            "Handle new call with {}, we are the {}",
            contactIdentity,
            isInitiator ? "caller" : "callee"
        );

        // Cancel any pending notifications
        if (!isInitiator) {
            dependencies.getVoipStateService().cancelCallNotificationsForNewCall();
        }

        // Get contact model from intent parameters
        ContactModel newContact = dependencies.getContactModelRepository().getByIdentity(contactIdentity);
        ContactModelData contactModelData = newContact != null ? newContact.getData() : null;
        if (newContact == null || contactModelData == null) {
            // We cannot initialize a new call if the contact cannot be looked up.
            this.abortCall(R.string.voip_error_init_call, "Cannot retrieve contact for provided contact", false);
            return;
        } else {
            logCallInfo(
                callId,
                "Found contact with identity {}",
                newContact.getIdentity()
            );
            contactModel = newContact;
            VoipCallService.contactModelData = contactModelData;
        }

        // Initialize state variables
        this.transportConnected = false;
        this.isError = false;
        dependencies.getVoipStateService().setStateInitializing(callId, contactIdentity);

        // Can we use videocalls?
        if (this.videoEnabled && !ConfigUtils.isVideoCallsEnabled()) {
            logCallInfo(callId, "videoEnabled=false, disabled via user config");
            this.videoEnabled = false;
        }
        if (this.videoEnabled && !ThreemaFeature.canVideocall(contactModelData.featureMaskLong())) {
            logCallInfo(callId, "videoEnabled=false, remote feature mask does not support video calls");
            this.videoEnabled = false;
        }

        // If we're the responder, we also got the SDP data from the initial offer.
        if (!isInitiator) {
            // Look up CallOffer
            final VoipCallOfferData callOfferData = dependencies.getVoipStateService().getCallOffer(callId);
            if (callOfferData == null) {
                abortCall(R.string.voip_error_init_call, "Call offer for Call ID " + callId + " not found", false);
                return;
            }

            // Ensure that offer SDP exists
            final VoipCallOfferData.OfferData offerData = callOfferData.getOfferData();
            if (offerData == null || offerData.getSdp() == null || offerData.getSdpType() == null) {
                abortCall(R.string.voip_error_init_call, "Call offer does not contain SDP", true);
                return;
            }
            final SessionDescription.Type sdpType = SdpUtil.getSdpType(offerData.getSdpType());
            if (sdpType == null) {
                abortCall(R.string.voip_error_init_call, String.format("handleNewCall: Invalid sdpType: %s", offerData.getSdpType()), true);
                return;
            }
            logCallInfo(callId, "Initializing this.offerSessionDescription");
            this.offerSessionDescription = new SessionDescription(sdpType, offerData.getSdp());

            // If the offerer does not signal video support, disable it
            final FeatureList offerCallFeatures = callOfferData.getFeatures();
            if (!offerCallFeatures.hasFeature(VideoFeature.NAME)) {
                logCallInfo(callId, "videoEnabled=false, remote does not signal support for video calls");
                this.videoEnabled = false;
            }
        }

        // Initialize peer connection parameters
        if (HAS_DEV_FEATURES) {
            // Don't use hardware echo cancellation on debug and internal builds. Note that we
            // override this setting to test software echo cancellation internally.
            this.useHardwareEC = false;
        } else {
            // If the stored AEC mode is 'hw', we use hardware echo cancellation
            this.useHardwareEC = dependencies.getPreferenceService().getAECMode().equals("hw");
        }
        final boolean useHardwareNC = true; // Noise suppression
        final boolean videoCallEnabled = this.videoEnabled;
        final String videoCodec = dependencies.getPreferenceService().getVideoCodec();
        final boolean videoCodecHwAcceleration = this.videoEnabled && !videoCodec.equals(PreferenceService.VIDEO_CODEC_SW);
        final boolean videoCodecEnableVP8 = !videoCodec.equals(PreferenceService.VIDEO_CODEC_NO_VP8);
        final boolean videoCodecEnableH264HiP = !videoCodec.equals(PreferenceService.VIDEO_CODEC_NO_H264HIP);
        final SdpPatcher.RtpHeaderExtensionConfig rtpHeaderExtensionConfig =
            this.videoEnabled
                ? SdpPatcher.RtpHeaderExtensionConfig.ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER
                : SdpPatcher.RtpHeaderExtensionConfig.DISABLE;
        final boolean gatherContinually = true;

        boolean forceTurn;
        if (contactModelData.verificationLevel == VerificationLevel.UNVERIFIED) {
            // Force TURN if the contact is unverified, to hide the local IP address.
            // This makes sure that a stranger cannot find out your IP simply by calling you.
            logCallInfo(callId, "Force TURN since contact is unverified");
            forceTurn = true;
        } else {
            // Don't force turn for verified contacts unless the user explicitly enabled
            // the setting.
            forceTurn = dependencies.getSynchronizedSettingsService().isForceTURN();
            if (forceTurn) {
                logCallInfo(callId, "Force TURN as requested by user");
            }
        }

        final PeerConnectionClient.PeerConnectionParameters peerConnectionParameters = new PeerConnectionClient.PeerConnectionParameters(
            false,
            this.useHardwareEC,
            useHardwareNC,
            videoCallEnabled,
            videoCodecHwAcceleration,
            videoCodecEnableVP8,
            videoCodecEnableH264HiP,
            rtpHeaderExtensionConfig,
            forceTurn,
            gatherContinually,
            dependencies.getPreferenceService().allowWebrtcIpv6()
        );

        // Initialize peer connection
        final @Nullable VideoContext videoContext = dependencies.getVoipStateService().getVideoContext();
        if (videoContext == null) {
            throw new IllegalStateException("Video context is null");
        }
        this.peerConnectionClient = new PeerConnectionClient(
            appContext,
            peerConnectionParameters,
            videoContext.getEglBaseContext(),
            callId
        );
        this.peerConnectionClient.setEventHandler(VoipCallService.this);

        try {
            boolean factoryCreated = this.peerConnectionClient
                .createPeerConnectionFactory()
                .get(10, TimeUnit.SECONDS);
            if (!factoryCreated) {
                this.abortCall(R.string.voip_error_init_call, "Peer connection factory could not be created", true);
            }
        } catch (InterruptedException e) {
            this.abortCall(R.string.voip_error_init_call, "Interrupted while creating peer connection factory", e, false);
            return;
        } catch (ExecutionException e) {
            this.abortCall(R.string.voip_error_init_call, "Exception while waiting for peer connection factory", e, true);
            return;
        } catch (TimeoutException e) {
            this.abortCall(R.string.voip_error_init_call, "Failed to create peer connection factory within 10 seconds", e, true);
            return;
        }

        // Maybe enable statistics callback
        this.enableUIDebugStats(this.uiDebugStatsEnabled);

        // directly enable camera if desired
        boolean launchVideo = false;
        if (isInitiator) {
            if (intent.getBooleanExtra(VoipCallService.EXTRA_LAUNCH_VIDEO, false)) {
                if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
                    launchVideo = true;
                    intent.putExtra(VoipCallService.EXTRA_LAUNCH_VIDEO, false);
                }
            }
        }

        // Start the call
        startCall(launchVideo);
    }

    @UiThread
    public void onToggleMic() {
        micEnabled = !micEnabled;

        final long callId = dependencies.getVoipStateService().getCallState().getCallId();
        logCallDebug(callId, "onToggleMic enabled = {}", micEnabled);

        if (peerConnectionClient != null) {
            peerConnectionClient.setLocalAudioTrackEnabled(micEnabled);
        }
        if (audioManager != null) {
            audioManager.setMicEnabled(micEnabled);
        }
    }

    @UiThread
    public synchronized void onToggleAudioDevice(AudioDevice audioDevice) {
        final long callId = dependencies.getVoipStateService().getCallState().getCallId();
        logCallInfo(callId, "Change audio device to {}", audioDevice);
        if (this.audioManager != null) {
            // Do the switch if possible
            if (this.audioManager.hasAudioDevice(audioDevice)) {
                this.audioManager.selectAudioDevice(audioDevice);
            } else {
                this.showSingleToast("Cannot switch to " + audioDevice, Toast.LENGTH_LONG);
                logCallError(callId, "Cannot switch to {}: Device not available", audioDevice);
            }
        } else {
            this.showSingleToast("Cannot change audio device", Toast.LENGTH_LONG);
            logCallError(callId, "Cannot change audio device: Audio manager is null");
        }
    }

    @AnyThread
    private synchronized void enableUIDebugStats(boolean enable) {
        logger.info("Enable UI debug stats: {}", enable);
        if (this.peerConnectionClient == null) {
            logger.error("Cannot enable/disable UI debug stats: Peer connection client is null");
            return;
        }
        this.uiDebugStatsEnabled = enable;
        if (enable) {
            if (!this.peerConnectionClient.isPeriodicStatsRegistered(this.activityStatsCollector)) {
                this.peerConnectionClient.registerPeriodicStats(
                    this.activityStatsCollector,
                    VoipCallService.ACTIVITY_STATS_INTERVAL_MS
                );
            }
        } else {
            this.peerConnectionClient.unregisterPeriodicStats(this.activityStatsCollector);
        }
    }

    @UiThread
    private synchronized void startCall(boolean launchVideo) {
        final long callId = dependencies.getVoipStateService().getCallState().getCallId();
        logCallInfo(callId, "Start call");

        this.callStartedTimeMs = System.currentTimeMillis();
        callStartedRealtimeMs = SystemClock.elapsedRealtime();

        // Show notification
        this.showInCallNotification(this.callStartedTimeMs, callStartedRealtimeMs);

        logCallInfo(callId, "Video calls are {}", this.videoEnabled ? "enabled" : "disabled");

        final @Nullable VideoContext videoContext = dependencies.getVoipStateService().getVideoContext();

        // Make sure that the peerConnectionClient is initialized
        final @StringRes int initError = R.string.voip_error_init_call;
        if (this.peerConnectionClient == null) {
            this.abortCall(initError, "Cannot start call: peerConnectionClient is not initialized", false);
            return;
        } else if (contactModel == null) {
            this.abortCall(initError, "Cannot start call: contact is not initialized", false);
            return;
        } else if (this.videoEnabled && videoContext == null) {
            this.abortCall(initError, "Cannot start call: video context is not initialized", false);
            return;
        }
        logCallInfo(callId, "Setting up call with {}", contactModel.getIdentity());

        // Create and audio manager that will take care of audio routing,
        // audio modes, audio device enumeration etc.
        this.audioManager = VoipAudioManager.create(
            getApplicationContext(),
            dependencies.getVoipStateService(),
            dependencies.getPreferenceService()
        );
        VoipListenerManager.audioManagerListener.add(this.audioManagerListener);
        logCallInfo(callId, "Starting the audio manager...");
        this.audioManager.start();

        // Create peer connection
        logCallInfo(callId, "Creating peer connection, delay={}ms", System.currentTimeMillis() - this.callStartedTimeMs);
        final @Nullable VideoSink localVideoSink = videoContext != null ? videoContext.getLocalVideoSinkProxy() : null;
        final @Nullable VideoSink remoteVideoSink = videoContext != null ? videoContext.getRemoteVideoSinkProxy() : null;
        peerConnectionClient.createPeerConnection(localVideoSink, remoteVideoSink);

        // Set initial video quality parameters
        this.updateOwnVideoQualityProfile(
            Boolean.TRUE.equals(this.meteredStatusChangedReceiver.getMetered().getValue()),
            this.networkIsRelayed
        );

        // Initialize peer connection
        logCallInfo(
            callId, "Initializing call, voipStateService.callId = {}, voipStateService.isInitiator = {}",
            dependencies.getVoipStateService().getCallState().getCallId(), dependencies.getVoipStateService().isInitiator()
        );
        if (dependencies.getVoipStateService().isInitiator() == Boolean.TRUE) {
            this.initAsInitiator(callId, launchVideo);
        } else {
            this.initAsResponder(callId);
        }
    }

    @UiThread
    private void initAsInitiator(long callId, final boolean launchVideo) {
        logCallInfo(callId, "Init call as initiator");

        // Make sure that the peerConnectionClient is initialized
        if (this.peerConnectionClient == null) {
            this.abortCall(R.string.voip_error_init_call, "Cannot initialize: peerConnectionClient is null", false);
            return;
        }

        // Register listeners
        this.voipMessageListener = new VoipMessageListener() {
            @Override
            public synchronized void onOffer(final String identity, final VoipCallOfferData data) {
                logCallError(data.getCallIdOrDefault(0L), "Received offer as initiator");
            }

            @Override
            public synchronized void onAnswer(final String identity, final VoipCallAnswerData data) {
                final long callId = data.getCallIdOrDefault(0L);
                logCallInfo(callId, "Received answer: {}", data.getAction());

                // Make sure that the peerConnectionClient is initialized
                if (peerConnectionClient == null) {
                    logCallError(callId, "Ignoring answer: peerConnectionClient is not initialized");
                    return;
                }

                // Check state
                final CallStateSnapshot callState = dependencies.getVoipStateService().getCallState();
                if (!callState.isInitializing()) {
                    logCallError(callId, "Ignoring answer: callState is {}", callState);
                    return;
                }

                // Check contact in answer
                if (contactModel == null) {
                    logCallError(callId, "Ignoring answer: contact is not initialized");
                    return;
                } else if (!areEqual(contactModel.getIdentity(), identity)) {
                    logCallError(callId, "Ignoring answer: Does not match current contact");
                    return;
                }

                // Parse action
                if (data.getAction() == null) {
                    logCallError(callId, "Ignoring answer: Action is null");
                    return;
                }
                switch (data.getAction()) {
                    case VoipCallAnswerData.Action.ACCEPT:
                        break;
                    case VoipCallAnswerData.Action.REJECT:
                        // Log event
                        logCallInfo(callId, "Call to {} was rejected (reason code: {})",
                            contactModel.getIdentity(), data.getRejectReason());

                        // Stop ringing tone
                        stopLoopingSound(callId);

                        // Update UI to show disconnecting status
                        preDisconnect(callId);

                        // Disconnect after a while
                        new Handler(Looper.getMainLooper()).postDelayed(
                            () -> {
                                // Only disconnect the call with the correct call id because a new
                                // call might be already running. The new call should not be
                                // disconnected.
                                long currentCallId = dependencies.getVoipStateService().getCallState().getCallId();
                                if (currentCallId == callId) {
                                    disconnect();
                                } else {
                                    logger.warn("Do not disconnect as another call is already running");
                                }
                            },
                            4050 /* busy sound takes 4*1 seconds. add 50ms delay budget. */
                        );

                        playSound(callId, VoipSound.BUSY);

                        return;
                    default:
                        abortCall(
                            R.string.voip_error_processing_call_anser,
                            "Invalid call answer action: " + data.getAction(),
                            false
                        );
                        return;
                }

                // Parse session description
                final VoipCallAnswerData.AnswerData answerData = data.getAnswerData();
                if (answerData == null) {
                    logCallError(callId, "Ignoring answer: Answer data is null");
                    return;
                }
                final @Nullable SessionDescription sessionDescription = SdpUtil.getAnswerSessionDescription(answerData);
                if (sessionDescription == null) {
                    abortCall(
                        R.string.voip_error_processing_call_anser,
                        String.format("Received invalid answer SDP: %s / %s", answerData.getSdpType(), answerData.getSdp()),
                        false
                    );
                    return;
                }

                // Detect video support in answer
                if (!data.getFeatures().hasFeature(VideoFeature.NAME)) {
                    logCallInfo(callId, "videoEnabled=false, remote does not signal support for video calls");
                    videoEnabled = false;
                    VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_DISABLE_VIDEO);
                }

                // Set remote description
                peerConnectionClient.setRemoteDescription(sessionDescription);

                // Now that the answer is set, we don't need to listen for further messages.
                VoipListenerManager.messageListener.remove(VoipCallService.this.voipMessageListener);
            }

            @Override
            public void onRinging(String identity, final VoipCallRingingData data) {
                long callId = data.getCallIdOrDefault(0L);
                logCallInfo(callId, "Peer device is ringing");

                // Cancel the running initialization timeout task
                synchronized (initializationTimeoutTimer) {
                    if (initializationTimeoutTask != null) {
                        initializationTimeoutTask.cancel();
                    }
                }

                startLoopingSound(callId, VoipSound.RINGING);
                VoipUtil.sendVoipBroadcast(appContext, CallActivity.ACTION_PEER_RINGING);
                dependencies.getVoipStateService().setPeerRinging(true);

                if (launchVideo) {
                    startCapturing();
                }
            }

            @Override
            public void onHangup(String identity, final VoipCallHangupData data) {
                logCallInfo(data.getCallIdOrDefault(0L), "Received hangup from peer");
            }

            @Override
            public boolean handle(final String identity) {
                return contactModel != null && areEqual(contactModel.getIdentity(), identity);
            }
        };
        VoipListenerManager.messageListener.add(this.voipMessageListener);

        startLoopingSound(callId, VoipSound.INITIALIZING);

        logCallInfo(callId, "Creating offer...");
        this.peerConnectionClient.createOffer();

        startInitializationTimeout(
            () -> {
                logCallInfo(
                    callId,
                    "Initialization timed out after {} milliseconds - hanging up",
                    INITIALIZATION_TIMEOUT_MS
                );
                // Play problem sound and hang up
                this.playHangupSoundOnConnectionClosed = false;
                playSound(
                    callId,
                    VoipSound.PROBLEM,
                    () -> RuntimeUtil.runOnUiThread(
                        () -> onCallHangUp(R.string.voip_could_not_initialize)
                    )
                );
            }
        );
    }

    /**
     * Starts a new timer that checks if the voip state is still {@code CallState.INITIALIZING} after a defined timeout of
     * {@code TIMEOUT_INITIALIZATION_SECONDS}. Any previous timer gets cancelled.
     */
    @AnyThread
    private void startInitializationTimeout(final @NonNull Runnable onInitializationTimedOut) {
        synchronized (initializationTimeoutTimer) {
            if (initializationTimeoutTask != null) {
                initializationTimeoutTask.cancel();
            }
            initializationTimeoutTask = new TimerTask() {
                @Override
                public void run() {
                    final boolean isStillInitializing = dependencies.getVoipStateService().getCallState().isInitializing();
                    if (isStillInitializing) {
                        onInitializationTimedOut.run();
                    }
                }
            };
            try {
                initializationTimeoutTimer.schedule(
                    initializationTimeoutTask,
                    INITIALIZATION_TIMEOUT_MS
                );
            } catch (Exception e) {
                logger.error("Failed to schedule initialization timeout task", e);
            }
        }
    }

    @UiThread
    private void initAsResponder(long callId) {
        logCallInfo(callId, "Init call as responder");

        // Make sure that the peerConnectionClient is initialized
        if (this.peerConnectionClient == null) {
            abortCall(R.string.voip_error_init_call, "this.peerConnectionClient is null, even though it should be initialized", true);
            return;
        }

        // Parse offer session description
        if (this.offerSessionDescription == null) {
            abortCall(R.string.voip_error_init_call, "this.offerSessionDescription is null, even though it should be initialized", true);
            return;
        }

        // Set remote description
        logCallInfo(callId, "Setting remote description");
        this.peerConnectionClient.setRemoteDescription(this.offerSessionDescription);
    }

    /**
     * A new candidate message was received.
     */
    @UiThread
    private void handleNewCandidate(
        final String contactIdentity,
        final @NonNull VoipICECandidatesData candidatesData
    ) {
        final long currentCallId = dependencies.getVoipStateService().getCallState().getCallId();

        // Sanity checks
        if (contactModel == null) {
            logCallInfo(currentCallId, "Ignore candidates from broadcast, contact hasn't been initialized yet");
            return;
        }
        if (!areEqual(contactIdentity, contactModel.getIdentity())) {
            logCallInfo(
                currentCallId,
                "Ignore candidates from broadcast targeted at another identity (current {}, target {})",
                contactModel.getIdentity(),
                contactIdentity
            );
            return;
        }

        logCallInfo(currentCallId, "Process candidates from broadcast");
        this.processCandidates(currentCallId, candidatesData);
    }

    /**
     * Called as soon as the peer connection has been established.
     */
    @AnyThread
    private synchronized void callConnected(final long callId) {
        final long delta = System.currentTimeMillis() - this.callStartedTimeMs;
        if (BuildConfig.DEBUG) {
            this.showSingleToast("Call " + callId + " connected: delay=" + delta + "ms", Toast.LENGTH_LONG);
        }
        if (this.peerConnectionClient == null || this.isError) {
            this.abortCall(R.string.voip_error_call, callId + ": Call is connected in closed or error state", false);
            return;
        }

        // Update state
        dependencies.getVoipStateService().setStateCalling(callId, getPeerIdentityOrNull());

        // Stop ringing tone
        this.stopLoopingSound(callId);

        this.playSound(callId, VoipSound.PICKUP);

        // Start call duration counter
        VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_CONNECTED);

        // Send inital configuration
        try (CloseableLock ignored = this.videoQualityNegotiation.read()) {
            if (this.localVideoQualityProfile != null) {
                this.peerConnectionClient.sendSignalingMessage(this.localVideoQualityProfile);
            }
        }

        // Notify listeners
        if (contactModel == null) {
            logCallError(callId, "contact is null in callConnected()");
        } else {
            final String contactIdentity = contactModel.getIdentity();
            final Boolean isInitiator = dependencies.getVoipStateService().isInitiator();
            if (isInitiator == null) {
                logCallError(callId, "voipStateService.isInitiator() is null in callConnected()");
            } else {
                globalEventBuses.getVoipCalls().emit(VoipCallEvent.Started.javaCreate(contactIdentity, isInitiator));
            }
        }
    }

    /**
     * This is run to initialize the disconnecting process.
     * <p>
     * The method is only needed if there is a delay between starting and finishing the
     * disconnection, e.g. when blocking the UI for a few seconds to play the "busy" sound.
     */
    @AnyThread
    private synchronized void preDisconnect(long callId) {
        logCallInfo(callId, "Pre-disconnect");
        if (!dependencies.getVoipStateService().getCallState().isIdle()) {
            dependencies.getVoipStateService().setStateDisconnecting(callId, getPeerIdentityOrNull());
            VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_PRE_DISCONNECT);
        }
    }

    /**
     * Clean up resources if they haven't been cleaned yet.
     * <p>
     * This method can safely be called multiple times.
     */
    private void cleanup() {
        logger.info("Cleaning up resources");

        // Stop timers
        synchronized (this.transportDisconnectedSoundTimer) {
            logger.info("Cancel iceDisconnectedSoundTimeout");
            if (this.transportDisconnectedSoundTimeout != null) {
                this.transportDisconnectedSoundTimeout.cancel();
                this.transportDisconnectedSoundTimeout = null;
            }
        }
        synchronized (this.initializationTimeoutTimer) {
            logger.info("Cancel initializationTimeoutTimer");
            if (this.initializationTimeoutTask != null) {
                this.initializationTimeoutTask.cancel();
                this.initializationTimeoutTask = null;
            }
            this.initializationTimeoutTimer.cancel();
        }

        // Remove listeners
        if (this.voipMessageListener != null) {
            VoipListenerManager.messageListener.remove(this.voipMessageListener);
            this.voipMessageListener = null;
        }

        // Close peerConnectionClient
        if (this.peerConnectionClient != null) {
            // Note: This needs to be here to ensure that the stats-after-closing contains all
            //       candidate pairs.
            this.transportConnected = false;

            synchronized (this) {
                logger.info("Unregister debug stats collector");
                // Unregister debug stats collector & do a final stats collection
                final VoipStats.Builder statsBuilder = new VoipStats.Builder()
                    .withSelectedCandidatePair(false)
                    .withTransport(true)
                    .withCrypto(true)
                    .withRtp(true)
                    .withTracks(true)
                    .withCodecs(false)
                    .withCandidatePairs(VoipStats.CandidatePairVariant.OVERVIEW_AND_DETAILED);
                this.peerConnectionClient.unregisterPeriodicStats(this.debugStatsCollector);
                this.debugStatsCollector = new CallStatsCollectorCallback(statsBuilder);
                this.peerConnectionClient.setAfterClosingStatsCallback(this.debugStatsCollector);

                // Unregister frame detector
                if (this.frameDetector != null) {
                    this.peerConnectionClient.unregisterPeriodicStats(this.frameDetector);
                    this.frameDetector = null;
                }
            }
            logger.info("Closing peer connection client");
            this.peerConnectionClient.close();
            this.peerConnectionClient = null;
        }

        // Stop audio manager
        if (this.audioManager != null) {
            logger.info("Stopping audio manager");
            VoipListenerManager.audioManagerListener.remove(this.audioManagerListener);
            this.audioManager.stop();
            this.audioManager = null;
        }

        // Stop media players
        if (this.mediaPlayer != null) {
            logger.info("Stopping and releasing ringing tone media player");
            this.mediaPlayer.stop();
            this.mediaPlayer.release();
            this.mediaPlayer = null;
        }

        contactModel = null;

        // Update state
        logger.info("Releasing video context, transition to IDLE state");
        // Release video context
        dependencies.getVoipStateService().releaseVideoContext();
        dependencies.getVoipStateService().setVideoRenderMode(VIDEO_RENDER_FLAG_NONE);
        dependencies.getVoipStateService().setStateIdle();

        logger.info("Cleanup done");
    }

    /**
     * Disconnect from remote resources, dispose of local resources, and exit.
     *
     * @param userMessageRes If specified, it is appended to the "Threema call has ended" toast.
     */
    @UiThread
    private synchronized void disconnect(final @Nullable @StringRes Integer userMessageRes) {
        // Get call ID (note: May already be reset to 0)
        final CallStateSnapshot callState = dependencies.getVoipStateService().getCallState();
        final long callId = callState.getCallId();

        final @Nullable String userMessage;
        if (userMessageRes != null) {
            userMessage = getString(userMessageRes);
        } else {
            userMessage = null;
        }

        logCallInfo(
            callId,
            "disconnect (isConnected? {} | isError? {} | message: {})",
            this.transportConnected, this.isError, userMessage
        );

        // If the call is still connected, notify event bus about the finishing
        if (callState.isCalling() && contactModel != null) {
            final String contactIdentity = contactModel.getIdentity();
            final Boolean isInitiator = dependencies.getVoipStateService().isInitiator();
            final Integer duration = dependencies.getVoipStateService().getCallDuration();
            if (isInitiator == null) {
                logger.error("isInitiator is null in disconnect()");
            } else if (duration == null) {
                logger.error("duration is null in disconnect()");
            } else {
                globalEventBuses.getVoipCalls().emit(VoipCallEvent.Finished.javaCreate(callId, contactIdentity, isInitiator, duration));
            }
        }

        this.preDisconnect(callId);

        this.cleanup();

        stopForeground(true);

        if (this.transportConnected && !this.isError) {
            VoipUtil.sendVoipBroadcast(this, CallActivity.ACTION_DISCONNECTED);
        } else {
            VoipUtil.sendVoipBroadcast(this, CallActivity.ACTION_CANCELLED);
        }

        if (!this.didShowDisconnectMessage) {
            String toastMessage = userMessage != null
                ? getString(R.string.void_call_finished_pattern, getString(R.string.voip_call_finished), userMessage)
                : getString(R.string.voip_call_finished);
            this.showSingleToast(toastMessage, Toast.LENGTH_LONG);
            this.didShowDisconnectMessage = true;
        }

        stopSelf();
    }

    @UiThread
    private synchronized void disconnect() {
        this.disconnect(null);
    }

    /**
     * Add or remove ICE candidates.
     */
    private void processCandidates(long callId, @NonNull VoipICECandidatesData data) {
        // Null check
        if (this.peerConnectionClient == null) {
            logCallWarning(callId, "Ignored ICE candidate message, peerConnectionClient is null");
            return;
        }

        // IPv6 check
        if (!dependencies.getPreferenceService().allowWebrtcIpv6()) {
            final int prevSize = data.getCandidates() != null ? data.getCandidates().length : 0;
            data.filter(candidate -> !SdpUtil.isIpv6Candidate(candidate.getCandidate()));
            final int newSize = data.getCandidates().length;
            if (newSize < prevSize) {
                logCallInfo(callId, "Ignored {} remote IPv6 candidate (disabled via preferences)", prevSize - newSize);
            }
        }

        // Add or remove candidates
        final IceCandidate[] candidates = SdpUtil.getIceCandidates(data.getCandidates());
        for (IceCandidate candidate : candidates) {
            this.peerConnectionClient.addRemoteIceCandidate(candidate);
        }

        // Log candidates
        logCallInfo(callId, "Added {} VoIP ICE candidate(s):", candidates.length);
        for (IceCandidate candidate : candidates) {
            logCallInfo(callId, "  Incoming candidate: {}", candidate.sdp);
        }
    }

    /**
     * Show a toast. Runs on the UI thread.
     */
    @AnyThread
    private void showSingleToast(final String msg, final int length) {
        RuntimeUtil.runOnUiThread(() -> SingleToast.getInstance().showBottom(msg, length));
    }

    @Nullable
    private String getPeerIdentityOrNull() {
        return contactModel != null ? contactModel.getIdentity() : null;
    }

    public VoipCallService() {
        super();
    }

    //region Error handling / reporting

    // abort call:
    // - with / without user message
    // - with / without error notification

    /**
     * Abort call due to an error.
     * <p>
     * The message(s) will be logged, followed by a disconnect.
     *
     * @param userMessageRes  A user facing message that's shown in the post-call toast message.
     * @param internalMessage An (optional) internal message that's being logged.
     * @param throwable       A (optional) {@link Throwable} that's logged.
     * @param reportError     If set to true, the message and throwable will be reported (to Sentry, if applicable) and the user is informed via toast
     */
    @AnyThread
    private synchronized void abortCall(
        @StringRes final int userMessageRes,
        @NonNull final String internalMessage,
        @Nullable final Throwable throwable,
        boolean reportError
    ) {
        // Log error
        final long callId = dependencies.getVoipStateService().getCallState().getCallId();
        if (throwable != null) {
            logCallError(callId, "Aborting call: {}", internalMessage, throwable);
        } else {
            logCallError(callId, "Aborting call: {}", internalMessage);
        }

        // Update isError
        boolean wasError = this.isError;
        this.isError = true;

        // If an error occurs during initialization of the service, before `startForeground`
        // was called, Android complains:
        //
        //    android.app.RemoteServiceException: Context.startForegroundService()
        //        did not then call Service.startForeground()
        //
        // See https://issuetracker.google.com/issues/76112072#comment36.
        //
        // To avoid this, we start the foreground notification (which will be killed again
        // as soon as we disconnect).
        if (!this.foregroundStarted) {
            this.showInCallNotification(this.callStartedTimeMs, callStartedRealtimeMs);
        }

        // ensure that only one error is logged by checking `wasError`
        if (reportError && !wasError) {
            logAndReportError(logger, "Error during Threema call: {}", internalMessage, throwable);
            showSingleToast(getString(R.string.voip_error_call), Toast.LENGTH_LONG);
        }

        RuntimeUtil.runOnUiThread(() -> disconnect(userMessageRes));
    }

    /**
     * Throwable defaults to {@code null}.
     *
     * @see #abortCall(int, String, Throwable, boolean)
     */
    @AnyThread
    private synchronized void abortCall(@StringRes final int userMessageRes, @NonNull final String internalMessage, boolean reportError) {
        this.abortCall(userMessageRes, internalMessage, null, reportError);
    }

    //endregion

    //region Logging

    // Note: Because the VoipCallService is not tied to a single call ID, we need to specify
    //       the call ID for every logging call. These helper methods provide some boilerplate
    //       code to make this easier.

    private static void logCallTrace(long callId, String message) {
        logger.trace("[cid={}]: {}", callId, message);
    }

    private static void logCallTrace(long callId, @NonNull String message, Object... arguments) {
        logger.trace("[cid=" + callId + "]: " + message, arguments);
    }

    private static void logCallDebug(long callId, @NonNull String message, Object... arguments) {
        logger.debug("[cid=" + callId + "]: " + message, arguments);
    }

    private static void logCallInfo(long callId, String message) {
        logger.info("[cid={}]: {}", callId, message);
    }

    private static void logCallInfo(long callId, @NonNull String message, Object... arguments) {
        logger.info("[cid=" + callId + "]: " + message, arguments);
    }

    private static void logCallWarning(long callId, String message) {
        logger.warn("[cid={}]: {}", callId, message);
    }

    private static void logCallWarning(long callId, @NonNull String message, Object... arguments) {
        logger.warn("[cid=" + callId + "]: " + message, arguments);
    }

    private static void logCallError(long callId, String message) {
        logger.error("[cid={}]: {}", callId, message);
    }

    private static void logCallError(long callId, String message, Throwable t) {
        logger.error("[cid=" + callId + "]: " + message, t);
    }

    private static void logCallError(long callId, @NonNull String message, Object... arguments) {
        logger.error("[cid=" + callId + "]: " + message, arguments);
    }

    //endregion

    //region Peer connection events

    // -----Implementation of PeerConnectionClient.PeerConnectionEvents.---------
    // Send local peer connection SDP and ICE candidates to remote party.
    // All callbacks are invoked from peer connection client looper thread.

    @Override
    @AnyThread
    public void onLocalDescription(long callId, final SessionDescription sdp) {
        new Thread(() -> {
            logCallInfo(callId, "onLocalDescription");
            synchronized (VoipCallService.this) {
                final CallStateSnapshot callState = dependencies.getVoipStateService().getCallState();
                logCallInfo(callId, "Sending {} in call state {}", sdp.type, callState.getName());
                if (callState.isInitializing() || callState.isRinging()) {
                    try {
                        if (dependencies.getVoipStateService().isInitiator() == Boolean.TRUE) {
                            dependencies.getVoipStateService().sendCallOfferMessage(contactModel, callId, sdp, this.videoEnabled);
                        } else {
                            dependencies.getVoipStateService().sendAcceptCallAnswerMessage(contactModel, callId, sdp, this.videoEnabled);
                        }
                    } catch (ThreemaException | IllegalArgumentException e) {
                        this.abortCall(R.string.voip_error_init_call, "Could not send offer or answer message", e, false);
                    }
                } else {
                    logCallInfo(callId, "Discarding local description (wrong state)");
                }
            }
        }, callId + ".onLocalDescription").start();
    }

    @Override
    @AnyThread
    public void onRemoteDescriptionSet(long callId) {
        logCallInfo(callId, "onRemoteDescriptionSet");

        if (this.peerConnectionClient == null) {
            logCallError(callId, "Cannot create answer: peerConnectionClient is not initialized");
            return;
        }

        if (dependencies.getVoipStateService().isInitiator() == Boolean.FALSE) {
            logCallInfo(callId, "Creating answer...");
            peerConnectionClient.createAnswer();
        }
    }

    /**
     * Send a local ICE candidates.
     * <p>
     * Note: If the callState is not RINGING, INITIALIZING or CALLING,
     * then the candidate will be disposed.
     */
    private void sendIceCandidate(long callId, @NonNull final IceCandidate candidate) {
        try {
            // Make sure we're in a call state where ICE candidates are needed,
            // to prevent a "candidate leak" otherwise.
            final CallStateSnapshot callState = dependencies.getVoipStateService().getCallState();
            if (!(callState.isRinging() || callState.isInitializing() || callState.isCalling())) {
                logCallInfo(callId, "Disposing ICE candidate, callState is {}", callState.getName());
                return;
            }

            // Log candidate
            logCallInfo(callId, "Sending VoIP ICE candidate: {}", candidate.sdp);

            // Send
            dependencies.getVoipStateService().sendICECandidatesMessage(contactModel, callId, new IceCandidate[]{candidate});
        } catch (ThreemaException | IllegalArgumentException e) {
            logCallError(callId, "Could not send ICE candidate", e);
        }
    }

    @Override
    @AnyThread
    public void onIceCandidate(long callId, final IceCandidate candidate) {
        logCallTrace(callId, "onIceCandidate");

        // Send candidate
        logCallTrace(callId, "onIceCandidate: {}", candidate.sdp);
        VoipCallService.this.sendIceCandidate(callId, candidate);
    }

    @Override
    @AnyThread
    public void onTransportConnecting(long callId) {
        logCallInfo(callId, "Transport connecting");
        synchronized (this) {
            if (this.peerConnectionClient != null) {
                // Register debug stats collector (fast interval until connected)
                final VoipStats.Builder statsBuilder = new VoipStats.Builder()
                    .withSelectedCandidatePair(false)
                    .withTransport(true)
                    .withCrypto(true)
                    .withRtp(true)
                    .withTracks(true)
                    .withCodecs(false)
                    .withCandidatePairs(VoipStats.CandidatePairVariant.OVERVIEW_AND_DETAILED);
                this.peerConnectionClient.unregisterPeriodicStats(this.debugStatsCollector);
                this.debugStatsCollector = new CallStatsCollectorCallback(statsBuilder);
                this.peerConnectionClient.registerPeriodicStats(
                    this.debugStatsCollector,
                    VoipCallService.LOG_STATS_INTERVAL_MS_CONNECTING
                );
            }
        }
    }

    @Override
    @AnyThread
    public void onTransportConnected(long callId) {
        logCallInfo(callId, "Transport connected (wasConnected={})", this.transportWasConnected);
        this.transportConnected = true;
        if (this.transportWasConnected) {
            // If we were previously connected, then the connection problem sound
            // is scheduled or playing right now. Cancel and stop it.
            synchronized (this.transportDisconnectedSoundTimer) {
                if (this.transportDisconnectedSoundTimeout != null) {
                    this.transportDisconnectedSoundTimeout.cancel();
                    this.transportDisconnectedSoundTimeout = null;
                }
            }
            boolean wasPlaying = this.mediaPlayer != null;
            this.stopLoopingSound(callId);

            // Notify activity about reconnection
            VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_RECONNECTED);

            // Play pickup sound
            if (wasPlaying) {
                playSound(callId, VoipSound.PICKUP);
            }
        } else {
            // This is the initial "connected" event
            this.transportWasConnected = true;
            this.callConnected(callId);
            synchronized (this) {
                // Register debug stats collector (slow interval since we're connected)
                if (this.peerConnectionClient != null) {
                    final VoipStats.Builder statsBuilder = new VoipStats.Builder()
                        .withSelectedCandidatePair(true)
                        .withTransport(true)
                        .withCrypto(true)
                        .withRtp(true)
                        .withTracks(true)
                        .withCodecs(false)
                        .withCandidatePairs(VoipStats.CandidatePairVariant.OVERVIEW);
                    this.peerConnectionClient.unregisterPeriodicStats(this.debugStatsCollector);
                    this.debugStatsCollector = new CallStatsCollectorCallback(statsBuilder);
                    this.peerConnectionClient.registerPeriodicStats(this.debugStatsCollector, VoipCallService.LOG_STATS_INTERVAL_MS_CONNECTED);
                    if (this.videoEnabled) {
                        this.frameDetector = new FrameDetectorCallback(
                            this.remoteVideoStateDetector::onIncomingVideoFramesStarted,
                            this.remoteVideoStateDetector::onIncomingVideoFramesStopped
                        );
                        this.peerConnectionClient.registerPeriodicStats(this.frameDetector, VoipCallService.FRAME_DETECTOR_QUERY_INTERVAL_MS);
                    }
                }
            }
        }
    }

    @Override
    @AnyThread
    public void onTransportDisconnected(final long callId) {
        // Transport was disconnected. This can be a real closing of the connection,
        // or just a temporary connectivity issue that can be recovered.
        logCallInfo(callId, "Transport disconnected");
        this.transportConnected = false;

        // Notify activity about connectivity problems
        VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_RECONNECTING);

        // Start problem sound with some delay
        synchronized (this.transportDisconnectedSoundTimer) {
            this.transportDisconnectedSoundTimeout = new TimerTask() {
                @Override
                public void run() {
                    VoipCallService.this.startLoopingSound(callId, VoipSound.PROBLEM);
                    VoipCallService.this.transportDisconnectedSoundTimeout = null;
                }
            };
            this.transportDisconnectedSoundTimer.schedule(this.transportDisconnectedSoundTimeout, ICE_DISCONNECTED_SOUND_TIMEOUT_MS);
        }
    }

    @Override
    public void onTransportFailed(long callId) {
        logCallWarning(callId, "Transport failed");
        this.transportConnected = false;
        if (this.transportWasConnected) {
            // If we were previously connected, this means that the connection was closed.
            RuntimeUtil.runOnUiThread(() -> VoipCallService.this.disconnect(R.string.voip_call_disconnected));
        } else {
            // Otherwise we could never establish a connection in the first place.
            VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_CONNECTING_FAILED);

            // Send hangup message to notify peer that the connection attempt was aborted
            if (contactModel != null) {
                VoipStateService voipStateService = dependencies.getVoipStateService();
                voipStateService.sendCallHangupMessage(
                    contactModel,
                    voipStateService.getCallState(),
                    voipStateService.getCallDuration(),
                    voipStateService.isInitiator()
                );
            }

            // Play problem sound and disconnect
            playSound(
                callId,
                VoipSound.PROBLEM,
                () -> RuntimeUtil.runOnUiThread(
                    () -> VoipCallService.this.disconnect(R.string.voip_connection_failed)
                )
            );
        }
    }

    @Override
    public void onIceGatheringStateChange(long callId, PeerConnection.IceGatheringState newState) {
        logCallTrace(callId, "onIceGatheringStateChange");
    }

    @Override
    @AnyThread
    public void onPeerConnectionClosed(long callId) {
        logCallTrace(callId, "onPeerConnectionClosed");
        logCallInfo(callId, "Peer connection closed");
        if (this.playHangupSoundOnConnectionClosed) {
            playSound(callId, VoipSound.HANGUP);
        }
        RuntimeUtil.runOnUiThread(VoipCallService.this::disconnect);
    }

    @Override
    @AnyThread
    public void onError(long callId, final @NonNull String description, boolean abortCall) {
        // An error occurred in the peer connection. If the `abortCall` flag is set, we should abort the call.
        if (abortCall) {
            this.abortCall(
                R.string.voip_error_peer_connection,
                callId + ": " + description,
                false
            );
        }
    }

    @Override
    @WorkerThread
    public void onSignalingMessage(long callId, @NonNull Envelope envelope) {
        if (envelope.hasCaptureStateChange()) {
            this.handleCaptureStateChange(callId, envelope.getCaptureStateChange());
        } else if (envelope.hasVideoQualityProfile()) {
            this.handleVideoQualityProfileChange(callId, envelope.getVideoQualityProfile());
        } else {
            logCallWarning(callId, "onSignalingMessage: Unknown envelope variant");
        }
    }

    @Override
    public void onCameraFirstFrameAvailable() {
        // This event is triggered if the capturing camera reports the first available frame.
        // In general, we track the capturing state using the `isCapturing` variable, but in case
        // of a bug, there may be a mismatch between the assumed capturing state and the actual
        // capturing state. Therefore, to be on the safe side, this even will always override the
        // assumed capturing state by sending a "outgoing camera started" broadcast if the event
        // is received when the capturing state is assumed to be off.
        new Thread(() -> { // Start a thread to reduce the chance for deadlocks.
            synchronized (this.capturingLock) {
                if (!this.isCapturing) {
                    logger.error("WARNING: Received 'onCameraFirstFrameAvailable' event even though capturing should be off!");
                    VoipUtil.sendVoipBroadcast(
                        appContext,
                        CallActivity.ACTION_OUTGOING_VIDEO_STARTED
                    );
                }
            }
        }).start();
    }

    //endregion

    //region Sound playback

    private interface OnSoundComplete {
        void onComplete();
    }

    /**
     * Instantiate and start the looping sound media player.
     */
    @AnyThread
    private synchronized void startLoopingSound(
        final long callId,
        final @NonNull VoipSound sound
    ) {
        if (this.mediaPlayer != null) {
            this.mediaPlayer.stop();
            this.mediaPlayer.release();
            this.mediaPlayer = null;
        }
        logCallInfo(callId, "Looping {} sound...", sound.toString());

        // Initialize media player
        this.mediaPlayer = new MediaPlayerStateWrapper();
        this.mediaPlayer.setAudioStreamType(AudioManager.STREAM_VOICE_CALL);
        this.mediaPlayer.setLooping(true);

        // Load and play resource
        try (AssetFileDescriptor afd = getResources().openRawResourceFd(sound.resourceId)) {
            this.mediaPlayer.setDataSource(afd);
            this.mediaPlayer.prepare();
            this.mediaPlayer.start();
        } catch (Exception e) {
            logger.error("I/O Error", e);
            if (this.mediaPlayer != null) {
                this.mediaPlayer.release();
                this.mediaPlayer = null;
            }
        }
    }

    /**
     * Stop the currently playing looping sound.
     */
    @AnyThread
    private synchronized void stopLoopingSound(long callId) {
        if (this.mediaPlayer != null) {
            logCallInfo(callId, "Stopping looping sound...");
            this.mediaPlayer.stop();
            this.mediaPlayer.release();
            this.mediaPlayer = null;
        } else {
            logCallWarning(callId, "stopLoopingSound: mediaPlayer is null");
        }
    }

    /**
     * Play a one-time sound.
     */
    @AnyThread
    private synchronized void playSound(
        final long callId,
        final @NonNull VoipSound sound,
        final @Nullable OnSoundComplete onComplete
    ) {
        logCallInfo(callId, "Playing {} sound...", sound.toString());

        // Initialize media player
        final MediaPlayerStateWrapper soundPlayer = new MediaPlayerStateWrapper();
        soundPlayer.setAudioStreamType(AudioManager.STREAM_VOICE_CALL);
        soundPlayer.setLooping(false);

        // Load and play resource
        try (AssetFileDescriptor afd = getResources().openRawResourceFd(sound.resourceId)) {
            soundPlayer.setDataSource(afd);
            soundPlayer.prepare();
        } catch (IOException e) {
            logCallError(callId, "Could not play " + sound + " sound", e);
            soundPlayer.release();
        }

        soundPlayer.setCompletionListener(mp -> {
            mp.release();
            if (onComplete != null) {
                onComplete.onComplete();
            }
        });

        soundPlayer.start();
    }

    @AnyThread
    private synchronized void playSound(long callId, final @NonNull VoipSound sound) {
        this.playSound(callId, sound, null);
    }

    //endregion

    //region Notifications

    /**
     * Show the ongoing notification that is shown as long as the call is active.
     *
     * @param callStartedTimeMs Timestamp at which the call was started (wall time).
     * @param elapsedTimeMs     Timestamp at which the call was started (elapsed monotonic time since boot).
     */
    private synchronized void showInCallNotification(long callStartedTimeMs, long elapsedTimeMs) {
        if (contactModel == null || contactModelData == null) {
            logger.error("Cannot show in-call notification as the contact model or data is null");
            return;
        }

        logger.info("Show ongoing in-call notification");
        var identity = contactModel.getIdentity();

        // Prepare hangup action
        final Intent hangupIntent = new Intent(this, VoipCallService.class);
        hangupIntent.setAction(ACTION_HANGUP);
        final PendingIntent hangupPendingIntent = PendingIntent.getService(
            this,
            NotificationRequestCodes.CALL_HANG_UP,
            hangupIntent,
            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );

        // Prepare open action
        final Intent openIntent = new Intent(this, CallActivity.class);
        openIntent.putExtra(EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL);
        openIntent.putExtra(EXTRA_CONTACT_IDENTITY, identity);
        openIntent.putExtra(EXTRA_START_TIME, elapsedTimeMs);

        String contactName = contactModelData.getDisplayName(dependencies.getPreferenceService().getContactNameFormat(), true);

        final Person callerPerson = new Person.Builder()
            .setName(contactName)
            .setImportant(true)
            .build();

        final PendingIntent openPendingIntent = PendingIntent.getActivity(
            this,
            NotificationRequestCodes.getRequestCodeForConversationNotification(
                new ContactConversationId(identity),
                NotificationRequestCodes.ConversationNotificationAction.OPEN_CALL
            ),
            openIntent,
            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );

        // Prepare notification
        final NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_IN_CALL)
            .setContentTitle(contactName)
            .setContentText(getString(R.string.voip_title))
            .setLocalOnly(true)
            .setOngoing(true)
            .setGroup(NotificationGroups.CALLS)
            .setGroupSummary(false)
            .setUsesChronometer(true)
            .setWhen(callStartedTimeMs)
            .setSmallIcon(R.drawable.ic_phone_locked_white_24dp)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setContentIntent(openPendingIntent)
            .setStyle(NotificationCompat.CallStyle.forOngoingCall(callerPerson, hangupPendingIntent));

        final Bitmap avatar = dependencies.getContactService().getAvatar(contactModel.getIdentity(), false);
        notificationBuilder.setLargeIcon(avatar);
        Notification notification = notificationBuilder.build();
        notification.flags |= NotificationCompat.FLAG_NO_CLEAR | NotificationCompat.FLAG_ONGOING_EVENT;

        // Launch notification
        this.foregroundStarted = true;
        ServiceCompat.startForeground(
            this,
            IN_CALL_NOTIFICATION_ID,
            notification,
            FOREGROUND_SERVICE_TYPE
        );

        //call listener
        ListenerManager.voipCallListeners.handle(listener -> listener.onStart(contactModel.getIdentity(), elapsedTimeMs));
    }

    private void cancelInCallNotification() {
        if (notificationManagerCompat != null) {
            notificationManagerCompat.cancel(IN_CALL_NOTIFICATION_ID);
            //call listener
            ListenerManager.voipCallListeners.handle(VoipCallListener::onEnd);
        }
    }

    //endregion

    //region Video capturing

    /**
     * Start capturing (asynchronously).
     */
    @AnyThread
    private void startCapturing() {
        new Thread(() -> {
            if (this.peerConnectionClient != null) {
                try (CloseableLock ignored = this.videoQualityNegotiation.read()) {
                    synchronized (this.capturingLock) {
                        // Start capturing
                        final VideoCapturer videoCapturer = this.peerConnectionClient.startCapturing(
                            this.commonVideoQualityProfile
                        );
                        this.isCapturing = true;

                        // Query cameras
                        if (videoCapturer instanceof CameraVideoCapturer) {
                            final @Nullable VideoContext videoContext = dependencies.getVoipStateService().getVideoContext();
                            if (videoContext != null) {
                                Pair<String, String> primaryCameraNames = VideoCapturerUtil.getPrimaryCameraNames(appContext);
                                videoContext.setFrontCameraName(primaryCameraNames.first);
                                videoContext.setBackCameraName(primaryCameraNames.second);
                                videoContext.setCameraVideoCapturer((CameraVideoCapturer) videoCapturer);
                            }
                        }

                        // Notify listeners
                        VoipUtil.sendVoipBroadcast(appContext, CallActivity.ACTION_OUTGOING_VIDEO_STARTED);
                    }
                }
            }
        }, "StartCapturingThread").start();
    }

    /**
     * Stop capturing (asynchronously).
     */
    @AnyThread
    private void stopCapturing() {
        new Thread(() -> {
            if (peerConnectionClient != null) {
                synchronized (this.capturingLock) {
                    peerConnectionClient.stopCapturing();
                    this.isCapturing = false;
                    VoipUtil.sendVoipBroadcast(appContext, CallActivity.ACTION_OUTGOING_VIDEO_STOPPED);
                }
            }
        }, "StopCapturingThread").start();
    }

    /**
     * Update our own video quality profile, change outgoing video parameters
     * and notify the peer about this change.
     */
    @AnyThread
    private void updateOwnVideoQualityProfile(
        boolean networkIsMetered,
        boolean networkIsRelayed
    ) {
        logger.debug("updateOwnVideoQualityProfile: metered={} relayed={}", networkIsMetered, networkIsRelayed);
        try (CloseableLock ignored = this.videoQualityNegotiation.write()) {
            // Get own params from settings
            final VoipVideoParams ownParams = VoipVideoParams.getParamsFromSetting(
                dependencies.getPreferenceService().getVideoCallsProfile(),
                networkIsMetered
            );
            this.localVideoQualityProfile = ownParams;
            if (this.commonVideoQualityProfile == null) {
                this.commonVideoQualityProfile = ownParams;
            }
            if (this.peerConnectionClient != null) {
                // Notify peer about profile change
                // Note: When changing the local video quality profile before the peer connection
                //       was created, there will not yet be a data channel so signaling messages
                //       cannot be sent. However, this is no a problem since the local quality
                //       profile will be sent in `callConnected()`. That means that all quality
                //       profile signaling messages in states other than CALLING can be skipped.
                if (dependencies.getVoipStateService().getCallState().isCalling()) {
                    this.peerConnectionClient.sendSignalingMessage(ownParams);
                }

                // Adjust outgoing video stream
                try {
                    final VoipVideoParams common = ownParams.findCommonProfile(
                        this.remoteVideoQualityProfile,
                        networkIsRelayed
                    );
                    this.commonVideoQualityProfile = common;
                    synchronized (this.capturingLock) {
                        try {
                            this.peerConnectionClient.changeOutgoingVideoParams(common);
                        } catch (NullPointerException e) {
                            // This race condition can happen in rare cases if the peerConnectionClient
                            // has been discarded since the last null check. Ignore it.
                        }
                    }
                } catch (RuntimeException e) {
                    abortCall(
                        R.string.voip_error_determine_common_video_quality_profile,
                        "Could not determine common video quality profile",
                        e,
                        true
                    );
                }
            }
        }
    }

    /**
     * Update the peer video quality profile and change outgoing video parameters.
     */
    @AnyThread
    private void updatePeerVideoQualityProfile(@NonNull VoipVideoParams peerParams) {
        try (CloseableLock ignored = this.videoQualityNegotiation.write()) {
            this.remoteVideoQualityProfile = peerParams;
            if (this.peerConnectionClient != null) {
                // Adjust outgoing video stream
                try {
                    final VoipVideoParams common = peerParams.findCommonProfile(
                        this.localVideoQualityProfile,
                        this.networkIsRelayed
                    );
                    this.commonVideoQualityProfile = common;
                    synchronized (this.capturingLock) {
                        try {
                            this.peerConnectionClient.changeOutgoingVideoParams(common);
                        } catch (NullPointerException e) {
                            // This race condition can happen in rare cases if the peerConnectionClient
                            // has been discarded since the last null check. Ignore it.
                        }
                    }
                } catch (RuntimeException e) {
                    this.abortCall(
                        R.string.voip_error_determine_common_video_quality_profile,
                        "Could not determine common video quality profile",
                        e,
                        true
                    );
                }
            }
        }
    }

    /**
     * Switch between front- and back-camera.
     */
    private void switchCamera() {
        if (switchCamInProgress.get()) {
            logger.debug("Ignoring camera switch request, already in progress");
            return;
        }

        synchronized (this.capturingLock) {
            final @Nullable VideoContext videoContext = dependencies.getVoipStateService().getVideoContext();
            if (videoContext == null) {
                logger.warn("Ignoring camera switch request, no video context initialized");
                return;
            }
            final @Nullable CameraVideoCapturer capturer = videoContext.getCameraVideoCapturer();
            if (capturer == null) {
                logger.debug("Ignoring camera switch request, no capturer initialized");
                return;
            }
            logger.debug("Switching camera");
            switchCamInProgress.set(true);

            final @VideoContext.CameraOrientation int newCameraOrientation;
            final String newCameraName;
            if (videoContext.getCameraOrientation() == CAMERA_FRONT) {
                newCameraOrientation = CAMERA_BACK;
                newCameraName = videoContext.getBackCameraName();
            } else {
                newCameraOrientation = CAMERA_FRONT;
                newCameraName = videoContext.getFrontCameraName();
            }

            if (newCameraName == null) {
                logger.debug("Ignoring camera switch request, no camera with orientation='{}'", newCameraOrientation);
                return;
            }

            capturer.switchCamera(
                new CameraVideoCapturer.CameraSwitchHandler() {
                    @Override
                    public void onCameraSwitchDone(boolean isFront) {
                        final @Nullable VideoContext videoContext = dependencies.getVoipStateService().getVideoContext();
                        if (videoContext == null) {
                            logger.info("Ignoring camera switch done callback, no video context initialized");
                            return;
                        }
                        videoContext.setCameraOrientation(newCameraOrientation);
                        logger.info("Switched camera to {}", isFront ? "front cam" : "rear cam");
                        VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_CAMERA_CHANGED);
                        Toast.makeText(
                            appContext,
                            isFront ? R.string.voip_switch_cam_front : R.string.voip_switch_cam_rear,
                            Toast.LENGTH_SHORT
                        ).show();
                        this.resetInProgress();
                    }

                    @Override
                    public void onCameraSwitchError(String s) {
                        logger.info("Error while switching camera: {}", s);
                        this.resetInProgress();
                    }

                    private void resetInProgress() {
                        switchCamInProgress.set(false);
                    }
                },
                newCameraName
            );
        }
    }

    //endregion

    //region Remote video state

    private final @NonNull RemoteVideoStateDetector remoteVideoStateDetector = new RemoteVideoStateDetector(this::getApplicationContext);

    /**
     * This class handles remote video state changes (combining the
     * information from the capturing state signaling messages and
     * the frame detector).
     * <p>
     * The ACTION_INCOMING_VIDEO_STARTED and ACTION_INCOMING_VIDEO_STOPPED broadcasts
     * should never be sent outside this class.
     */
    private static class RemoteVideoStateDetector {
        private final @NonNull Supplier<Context> appContextSupplier;

        // Never access these variables from non-synchronized
        // methods to avoid data races!
        private volatile boolean incomingVideoFrames = false;
        private volatile boolean incomingVideoSignaled = false;
        private volatile boolean incomingVideo = false;

        RemoteVideoStateDetector(@NonNull Supplier<Context> appContextSupplier) {
            this.appContextSupplier = appContextSupplier;
        }

        /**
         * Called by the {@link #frameDetector}. Remote has started sending video frames.
         * <p>
         * Notify application if {@link #incomingVideo} was false.
         */
        synchronized void onIncomingVideoFramesStarted() {
            this.incomingVideoFrames = true;
            if (!this.incomingVideo) { // Incoming video was false
                this.incomingVideo = true;
                logger.info("Incoming video started (reason: frames)");
                VoipUtil.sendVoipBroadcast(
                    this.appContextSupplier.get(),
                    CallActivity.ACTION_INCOMING_VIDEO_STARTED
                );
            }
        }

        /**
         * Called by the {@link #frameDetector}. Remote has stopped sending video frames.
         * <p>
         * Notify application if {@link #incomingVideo} was false.
         */
        synchronized void onIncomingVideoFramesStopped() {
            this.incomingVideoFrames = false;
            if (this.incomingVideo) { // Incoming video was true...
                if (!this.incomingVideoSignaled) { // ...due to the frame detector
                    this.incomingVideo = false;
                    logger.info("Incoming video stopped (reason: frames)");
                    VoipUtil.sendVoipBroadcast(
                        this.appContextSupplier.get(),
                        CallActivity.ACTION_INCOMING_VIDEO_STOPPED
                    );
                }
                // Otherwise the remote still signals an enabled camera, so ignore the transition.
            }
        }

        /**
         * Called by {@link #handleCaptureStateChange(long, O2OCall.CaptureState)}.
         * Remote has signaled that video capturing has started.
         */
        synchronized void onRemoteVideoCapturingEnabled() {
            this.incomingVideoSignaled = true;
            if (!this.incomingVideo) {
                // Signaling always results in the video being shown
                this.incomingVideo = true;
                logger.info("Incoming video started (reason: signaling)");
                VoipUtil.sendVoipBroadcast(
                    this.appContextSupplier.get(),
                    CallActivity.ACTION_INCOMING_VIDEO_STARTED
                );
            }
        }

        /**
         * Called by {@link #handleCaptureStateChange(long, O2OCall.CaptureState)}.
         * Remote has signaled that video capturing has stopped.
         */
        synchronized void onRemoteVideoCapturingDisabled() {
            this.incomingVideoSignaled = false;
            if (this.incomingVideo) { // Incoming video was true...
                if (!this.incomingVideoFrames) { // ...due to the signaling state
                    this.incomingVideo = false;
                    logger.info("Incoming video stopped (reason: signaling)");
                    VoipUtil.sendVoipBroadcast(
                        this.appContextSupplier.get(),
                        CallActivity.ACTION_INCOMING_VIDEO_STOPPED
                    );
                }
            }
        }
    }

    //endregion

    //region Listeners

    private VoipAudioManagerListener audioManagerListener = new VoipAudioManagerListener() {
        @Override
        public void onAudioFocusLost(boolean temporary) {
            // WARNING: This method is currently not being called,
            // see commit ff68bb215c8e55f03b75128ebb40ae423585c5d9.

            logger.info("Audio focus lost. Transient = {}", temporary);

            if (temporary) {
                if (peerConnectionClient != null) {
                    peerConnectionClient.setLocalAudioTrackEnabled(false);
                    peerConnectionClient.setRemoteAudioTrackEnabled(false);
                    showSingleToast(appContext.getString(R.string.audio_mute_due_to_focus_loss), Toast.LENGTH_LONG);
                }
            } else {
                // lost forever - disconnect
                logAndReportError(logger, "Call disconnected due to complete audio focus loss");
                RuntimeUtil.runOnUiThread(
                    () -> disconnect(R.string.voip_audio_focus_lost)
                );
            }
        }

        @Override
        public void onAudioFocusGained() {
            logger.info("Audio focus gained");
            if (peerConnectionClient != null) {
                peerConnectionClient.setLocalAudioTrackEnabled(micEnabled);
                peerConnectionClient.setRemoteAudioTrackEnabled(true);
            }
        }
    };

    private class PSTNCallStateListener extends PhoneStateListener {
        @Override
        public void onCallStateChanged(int state, String phoneNumber) {
            super.onCallStateChanged(state, phoneNumber);
            if (state == TelephonyManager.CALL_STATE_OFFHOOK) {
                Toast.makeText(appContext, R.string.voip_another_pstn_call, Toast.LENGTH_LONG).show();
                onCallHangUp(null);
                logger.info("hanging up due to regular phone call");
            }
        }
    }

    //endregion

    //region Signaling message handlers

    /**
     * The call partner enabled or disabled capturing for a device.
     *
     * @param captureStateChange The received signaling message.
     */
    @AnyThread
    private void handleCaptureStateChange(long callId, @NonNull CaptureState captureStateChange) {
        logCallInfo(
            callId,
            "Signaling: Call partner changed {} capturing state to {}",
            captureStateChange.getDevice(),
            captureStateChange.getState()
        );

        // Handle camera capturing state changes
        if (CaptureState.CaptureDevice.CAMERA == captureStateChange.getDevice()) {
            switch (captureStateChange.getState()) {
                case ON:
                    this.remoteVideoStateDetector.onRemoteVideoCapturingEnabled();
                    break;
                case OFF:
                    this.remoteVideoStateDetector.onRemoteVideoCapturingDisabled();
                    break;
                default:
                    logCallWarning(callId, "Unknown capture state received");
            }
        }
    }

    /**
     * The call partner changed the video quality profile.
     *
     * @param videoQualityProfile The received signaling message.
     */
    @AnyThread
    private void handleVideoQualityProfileChange(long callId, @NonNull VideoQualityProfile videoQualityProfile) {
        logCallInfo(callId, "Signaling: Call partner changed video profile to {}", videoQualityProfile.getProfile());

        final VoipVideoParams profile = VoipVideoParams.fromSignalingMessage(videoQualityProfile);
        if (profile != null) {
            this.updatePeerVideoQualityProfile(profile);
        }
    }

    //endregion
}
