diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 4fb90ebded..85d4282dc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -47,7 +47,6 @@ import org.thoughtcrime.securesms.jobs.TypingSendJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.messages.IncomingMessageObserver; -import org.thoughtcrime.securesms.net.DefaultWebSocketShadowingBridge; import org.thoughtcrime.securesms.net.SignalWebSocketHealthMonitor; import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; import org.thoughtcrime.securesms.notifications.MessageNotifier; @@ -105,9 +104,7 @@ import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection; import org.whispersystems.signalservice.internal.websocket.LibSignalNetworkExtensions; import org.whispersystems.signalservice.internal.websocket.OkHttpWebSocketConnection; -import org.whispersystems.signalservice.internal.websocket.ShadowingWebSocketConnection; import org.whispersystems.signalservice.internal.websocket.WebSocketConnection; -import org.whispersystems.signalservice.internal.websocket.WebSocketShadowingBridge; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -302,8 +299,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { public @NonNull SignalWebSocket.AuthenticatedWebSocket provideAuthWebSocket(@NonNull Supplier signalServiceConfigurationSupplier, @NonNull Supplier libSignalNetworkSupplier) { SleepTimer sleepTimer = !SignalStore.account().isFcmEnabled() || SignalStore.internal().isWebsocketModeForced() ? new AlarmSleepTimer(context) : new UptimeSleepTimer(); SignalWebSocketHealthMonitor healthMonitor = new SignalWebSocketHealthMonitor(sleepTimer); - WebSocketShadowingBridge bridge = new DefaultWebSocketShadowingBridge(context); - WebSocketFactory webSocketFactory = provideWebSocketFactory(signalServiceConfigurationSupplier, healthMonitor, libSignalNetworkSupplier, bridge); + WebSocketFactory webSocketFactory = provideWebSocketFactory(signalServiceConfigurationSupplier, healthMonitor, libSignalNetworkSupplier); SignalWebSocket.AuthenticatedWebSocket webSocket = new SignalWebSocket.AuthenticatedWebSocket(webSocketFactory::createWebSocket); healthMonitor.monitor(webSocket); @@ -315,8 +311,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { public @NonNull SignalWebSocket.UnauthenticatedWebSocket provideUnauthWebSocket(@NonNull Supplier signalServiceConfigurationSupplier, @NonNull Supplier libSignalNetworkSupplier) { SleepTimer sleepTimer = !SignalStore.account().isFcmEnabled() || SignalStore.internal().isWebsocketModeForced() ? new AlarmSleepTimer(context) : new UptimeSleepTimer(); SignalWebSocketHealthMonitor healthMonitor = new SignalWebSocketHealthMonitor(sleepTimer); - WebSocketShadowingBridge bridge = new DefaultWebSocketShadowingBridge(context); - WebSocketFactory webSocketFactory = provideWebSocketFactory(signalServiceConfigurationSupplier, healthMonitor, libSignalNetworkSupplier, bridge); + WebSocketFactory webSocketFactory = provideWebSocketFactory(signalServiceConfigurationSupplier, healthMonitor, libSignalNetworkSupplier); SignalWebSocket.UnauthenticatedWebSocket webSocket = new SignalWebSocket.UnauthenticatedWebSocket(webSocketFactory::createUnidentifiedWebSocket); healthMonitor.monitor(webSocket); @@ -419,21 +414,18 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { @NonNull WebSocketFactory provideWebSocketFactory(@NonNull Supplier signalServiceConfigurationSupplier, @NonNull HealthMonitor healthMonitor, - @NonNull Supplier libSignalNetworkSupplier, - @NonNull WebSocketShadowingBridge bridge) + @NonNull Supplier libSignalNetworkSupplier) { return new WebSocketFactory() { @Override public WebSocketConnection createWebSocket() { if (RemoteConfig.libSignalWebSocketEnabled()) { Network network = libSignalNetworkSupplier.get(); - return new LibSignalChatConnection( - "libsignal-auth", - network, - new DynamicCredentialsProvider(), - Stories.isFeatureEnabled(), - healthMonitor - ); + return new LibSignalChatConnection("libsignal-auth", + network, + new DynamicCredentialsProvider(), + Stories.isFeatureEnabled(), + healthMonitor); } else { return new OkHttpWebSocketConnection("normal", signalServiceConfigurationSupplier.get(), @@ -446,28 +438,13 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { @Override public WebSocketConnection createUnidentifiedWebSocket() { - int shadowPercentage = RemoteConfig.libSignalWebSocketShadowingPercentage(); - if (shadowPercentage > 0) { - return new ShadowingWebSocketConnection( - "unauth-shadow", - signalServiceConfigurationSupplier.get(), - Optional.empty(), - BuildConfig.SIGNAL_AGENT, - healthMonitor, - Stories.isFeatureEnabled(), - libSignalNetworkSupplier.get(), - shadowPercentage, - bridge - ); - } if (RemoteConfig.libSignalWebSocketEnabled()) { Network network = libSignalNetworkSupplier.get(); - return new LibSignalChatConnection( - "libsignal-unauth", - network, - null, - Stories.isFeatureEnabled(), - healthMonitor); + return new LibSignalChatConnection("libsignal-unauth", + network, + null, + Stories.isFeatureEnabled(), + healthMonitor); } else { return new OkHttpWebSocketConnection("unidentified", signalServiceConfigurationSupplier.get(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/DefaultWebSocketShadowingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/net/DefaultWebSocketShadowingBridge.kt deleted file mode 100644 index 9d95db6942..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/net/DefaultWebSocketShadowingBridge.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.net - -import android.app.Application -import android.app.Notification -import android.app.PendingIntent -import android.content.Intent -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import org.signal.core.util.PendingIntentFlags -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.keyvalue.InternalValues -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity -import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.notifications.NotificationIds -import org.thoughtcrime.securesms.util.RemoteConfig -import org.whispersystems.signalservice.internal.websocket.WebSocketShadowingBridge - -/** - * Implements a [WebSocketShadowingBridge] to provide shadowing-specific functionality to - * [org.whispersystems.signalservice.internal.websocket.ShadowingWebSocketConnection] - */ -class DefaultWebSocketShadowingBridge(private val context: Application) : WebSocketShadowingBridge { - private val store: InternalValues = SignalStore.internal - - override fun writeStatsSnapshot(bytes: ByteArray) { - store.webSocketShadowingStats = bytes - } - - override fun readStatsSnapshot(): ByteArray? { - return store.webSocketShadowingStats - } - - override fun triggerFailureNotification(message: String) { - if (!RemoteConfig.internalUser) { - return - } - val notification: Notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("[Internal-only] $message") - .setContentText("Tap to send a debug log") - .setContentIntent( - PendingIntent.getActivity( - context, - 0, - Intent(context, SubmitDebugLogActivity::class.java), - PendingIntentFlags.mutable() - ) - ) - .build() - - NotificationManagerCompat.from(context).notify(NotificationIds.INTERNAL_ERROR, notification) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 3953d10d29..2d91d0b3b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1036,20 +1036,6 @@ object RemoteConfig { BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED || value.asBoolean(false) } - /** - * Percentage [0, 100] of web socket requests that will be "shadowed" by sending - * an unauthenticated keep-alive via libsignal-net. Default: 0 - */ - @JvmStatic - @get:JvmName("libSignalWebSocketShadowingPercentage") - val libSignalWebSocketShadowingPercentage: Int by remoteValue( - key = "android.libsignalWebSocketShadowingPercentage", - hotSwappable = false - ) { value -> - val remote = value.asInteger(0) - remote.coerceIn(0, 100) - } - @JvmStatic val backgroundMessageProcessInterval: Long by remoteValue( key = "android.messageProcessor.alarmIntervalMins", diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/ShadowingWebSocketConnection.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/ShadowingWebSocketConnection.kt deleted file mode 100644 index 58d43fe438..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/ShadowingWebSocketConnection.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.internal.websocket - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import okhttp3.Response -import okhttp3.WebSocket -import org.signal.core.util.logging.Log -import org.signal.libsignal.net.ChatConnection -import org.signal.libsignal.net.Network -import org.signal.libsignal.net.UnauthenticatedChatConnection -import org.whispersystems.signalservice.api.util.CredentialsProvider -import org.whispersystems.signalservice.api.websocket.HealthMonitor -import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState -import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration -import org.whispersystems.signalservice.internal.util.whenComplete -import java.util.Optional -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong -import kotlin.random.Random -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -/** - * A wrapper on top of [OkHttpWebSocketConnection] that sends a keep alive via - * libsignal-net for a configurable percentage of the _successful_ web socket requests. - * - * Stats are collected for the shadowed requests and persisted across app restarts - * using [org.thoughtcrime.securesms.keyvalue.InternalValues]. - * - * When a hardcoded error threshold is reached, the user is notified to submit debug logs. - * - * @see [org.thoughtcrime.securesms.util.RemoteConfig.libSignalWebSocketShadowingPercentage] - */ -class ShadowingWebSocketConnection( - name: String, - serviceConfiguration: SignalServiceConfiguration, - credentialsProvider: Optional, - signalAgent: String, - healthMonitor: HealthMonitor, - allowStories: Boolean, - private val network: Network, - private val shadowPercentage: Int, - private val bridge: WebSocketShadowingBridge -) : OkHttpWebSocketConnection( - name, - serviceConfiguration, - credentialsProvider, - signalAgent, - healthMonitor, - allowStories -) { - private var stats: Stats = try { - bridge.readStatsSnapshot()?.let { - Stats.fromSnapshot(it) - } ?: Stats() - } catch (ex: Exception) { - Log.w(TAG, "Failed to restore Stats from a snapshot.", ex) - Stats() - } - private val canShadow: AtomicBoolean = AtomicBoolean(false) - private val executor: ExecutorService = Executors.newSingleThreadExecutor() - private var chatConnection: UnauthenticatedChatConnection? = null - private var shadowingConnectPending = false - - override fun connect(): Observable { - // NB: The potential for race conditions here was introduced when we switched from ChatService's - // long lived connection model to the single-use ChatConnection model. - // At this time, we do not intend to ever use this code in production again, so I'm deferring properly - // fixing it with a refactor, and instead just doing the bare minimum to avoid an obvious race. - // If we do want to use this again in production, we should probably refactor to depend on the higher level - // LibSignalChatConnection, rather than the lower level ChatConnection API. - if (chatConnection == null && !shadowingConnectPending) { - shadowingConnectPending = true - executor.submit { - network.connectUnauthChat(null).whenComplete( - onSuccess = { connection -> - shadowingConnectPending = false - chatConnection = connection - canShadow.set(true) - Log.i(TAG, "Shadow socket connected.") - }, - onFailure = { - shadowingConnectPending = false - canShadow.set(false) - Log.i(TAG, "Shadow socket failed to connect.") - } - ) - } - } - return super.connect() - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - saveSnapshot() - super.onClosing(webSocket, code, reason) - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - saveSnapshot() - super.onFailure(webSocket, t, response) - } - - override fun disconnect() { - executor.submit { - chatConnection?.disconnect()?.thenApply { - canShadow.set(false) - Log.i(TAG, "Shadow socket disconnected.") - } - } - super.disconnect() - } - - override fun sendRequest(request: WebSocketRequestMessage): Single { - return super.sendRequest(request).doOnSuccess(::sendShadow) - } - - private fun sendShadow(actualResponse: WebsocketResponse) { - executor.submit { - if (canShadow.get() && Random.nextInt(100) < this.shadowPercentage) { - libsignalKeepAlive(actualResponse) - val snapshotAge = System.currentTimeMillis() - stats.lastSnapshot.get() - if (snapshotAge > SNAPSHOT_INTERVAL.inWholeMilliseconds) { - saveSnapshot() - } - } - } - } - - private fun shouldSubmitLogs(): Boolean { - val requestsCompared = stats.requestsCompared.get() - // Should not happen in practice, but helps avoid a division by zero later if it does. - if (requestsCompared == 0) { - return false - } - val timeSinceLastNotificationMs = System.currentTimeMillis() - stats.lastNotified.get() - val percentFailed = stats.failures.get() * 100 / requestsCompared - return timeSinceLastNotificationMs > FULL_DAY.inWholeMilliseconds && - percentFailed > FAILURE_PERCENTAGE - } - - private fun libsignalKeepAlive(actualResponse: WebsocketResponse) { - val connection = chatConnection ?: return - val request = ChatConnection.Request( - "GET", - "/v1/keepalive", - emptyMap(), - ByteArray(0), - KEEP_ALIVE_TIMEOUT.inWholeMilliseconds.toInt() - ) - connection.send(request) - ?.whenComplete( - onSuccess = { response -> - stats.requestsCompared.incrementAndGet() - val goodStatus = (response?.status ?: -1) in 200..299 - if (!goodStatus) { - stats.badStatuses.incrementAndGet() - } - Log.i(TAG, response?.message) - }, - onFailure = { - stats.requestsCompared.incrementAndGet() - stats.failures.incrementAndGet() - Log.w(TAG, "Shadow request failed: reason=[$it]") - Log.i(TAG, "Actual response status=${actualResponse.status}") - if (shouldSubmitLogs()) { - Log.i(TAG, "Notification to submit logs triggered.") - bridge.triggerFailureNotification("Experimental websocket transport failures!") - stats.reset() - } - } - ) - } - - private fun saveSnapshot() { - executor.submit { - Log.d(TAG, "Persisting shadowing stats snapshot.") - bridge.writeStatsSnapshot(stats.snapshot()) - } - } - - companion object { - private val TAG: String = Log.tag(ShadowingWebSocketConnection::class.java) - private val FULL_DAY: Duration = 1.days - - // If more than this percentage of shadow requests fail, the - // notification to submit logs will be triggered. - private const val FAILURE_PERCENTAGE: Int = 10 - private val KEEP_ALIVE_TIMEOUT: Duration = 3.seconds - private val SNAPSHOT_INTERVAL: Duration = 10.minutes - } - - class Stats( - requestsCompared: Int = 0, - failures: Int = 0, - badStatuses: Int = 0, - lastNotified: Long = 0 - ) { - val requestsCompared: AtomicInteger = AtomicInteger(requestsCompared) - val failures: AtomicInteger = AtomicInteger(failures) - val badStatuses: AtomicInteger = AtomicInteger(badStatuses) - val lastNotified: AtomicLong = AtomicLong(lastNotified) - val lastSnapshot: AtomicLong = AtomicLong(0) - - fun reset() { - requestsCompared.set(0) - failures.set(0) - badStatuses.set(0) - // Do not reset lastNotified nor lastSnapshot - } - - companion object { - fun fromSnapshot(bytes: ByteArray): Stats { - val snapshot = Snapshot.ADAPTER.decode(bytes) - return Stats(snapshot.requestsCompared, snapshot.failures, snapshot.badStatuses, snapshot.lastNotified) - } - } - - fun snapshot(): ByteArray { - lastSnapshot.set(System.currentTimeMillis()) - return Snapshot.Builder() - .requestsCompared(requestsCompared.get()) - .failures(failures.get()) - .badStatuses(badStatuses.get()) - .lastNotified(lastNotified.get()) - .build() - .encode() - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketShadowingBridge.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketShadowingBridge.kt deleted file mode 100644 index fcfcac5b03..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/WebSocketShadowingBridge.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.internal.websocket - -/** - * An interface to support app<->signal-service interop for the purposes of web socket shadowing. - */ -interface WebSocketShadowingBridge { - /** - * Persist shadowing stats snapshot. - */ - fun writeStatsSnapshot(bytes: ByteArray) - - /** - * Restore shadowing stats snapshot. - */ - fun readStatsSnapshot(): ByteArray? - - /** - * Display a notification the user to submit debug logs, with a custom message. - */ - fun triggerFailureNotification(message: String) -}