diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java deleted file mode 100644 index 528821068d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.dependencies.AppDependencies; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.net.SignalNetwork; -import org.thoughtcrime.securesms.util.ExceptionHelper; -import org.thoughtcrime.securesms.util.RemoteConfig; -import org.whispersystems.signalservice.api.NetworkResultUtil; -import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigResult; -import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; - -import java.util.concurrent.TimeUnit; - -public class RemoteConfigRefreshJob extends BaseJob { - - private static final String TAG = Log.tag(RemoteConfigRefreshJob.class); - - public static final String KEY = "RemoteConfigRefreshJob"; - - public RemoteConfigRefreshJob() { - this(new Job.Parameters.Builder() - .setQueue("RemoteConfigRefreshJob") - .setMaxInstancesForFactory(1) - .addConstraint(NetworkConstraint.KEY) - .setMaxAttempts(Parameters.UNLIMITED) - .setLifespan(TimeUnit.DAYS.toMillis(1)) - .build()); - } - - private RemoteConfigRefreshJob(@NonNull Parameters parameters) { - super(parameters); - } - - @Override - public @Nullable byte[] serialize() { - return null; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - protected void onRun() throws Exception { - if (!SignalStore.account().isRegistered()) { - Log.w(TAG, "Not registered. Skipping."); - return; - } - - RemoteConfigResult result = NetworkResultUtil.toBasicLegacy(SignalNetwork.remoteConfig().getRemoteConfig()); - RemoteConfig.update(result.getConfig()); - SignalStore.misc().setLastKnownServerTime(result.getServerEpochTimeMilliseconds(), System.currentTimeMillis()); - } - - @Override - protected boolean onShouldRetry(@NonNull Exception e) { - return ExceptionHelper.isRetryableIOException(e); - } - - @Override - public void onFailure() { - } - - public static final class Factory implements Job.Factory { - @Override - public @NonNull RemoteConfigRefreshJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) { - return new RemoteConfigRefreshJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt new file mode 100644 index 0000000000..5734067804 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.isNotNullOrBlank +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.api.NetworkResult +import kotlin.time.Duration.Companion.days + +/** + * Job to refresh remote configs. Utilizes eTags so a 304 is returned if content is unchanged since last fetch. + */ +class RemoteConfigRefreshJob private constructor(parameters: Parameters) : Job(parameters) { + companion object { + const val KEY: String = "RemoteConfigRefreshJob" + private val TAG = Log.tag(RemoteConfigRefreshJob::class.java) + } + + constructor() : this( + Parameters.Builder() + .setQueue(KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(1.days.inWholeMilliseconds) + .build() + ) + + override fun serialize(): ByteArray? { + return null + } + + override fun getFactoryKey(): String { + return KEY + } + + override fun run(): Result { + if (!SignalStore.account.isRegistered) { + Log.w(TAG, "Not registered. Skipping.") + return Result.success() + } + + return when (val result = SignalNetwork.remoteConfig.getRemoteConfig(SignalStore.remoteConfig.eTag)) { + is NetworkResult.Success -> { + RemoteConfig.update(result.result.config) + SignalStore.misc.setLastKnownServerTime(result.result.serverEpochTimeMilliseconds, System.currentTimeMillis()) + if (result.result.eTag.isNotNullOrBlank()) { + SignalStore.remoteConfig.eTag = result.result.eTag + } + Result.success() + } + + is NetworkResult.ApplicationError -> Result.failure() + is NetworkResult.NetworkError -> Result.retry(defaultBackoff()) + is NetworkResult.StatusCodeError -> + if (result.code == 304) { + Log.i(TAG, "Remote config has not changed since last pull.") + Result.success() + } else { + Result.retry(defaultBackoff()) + } + } + } + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): RemoteConfigRefreshJob { + return RemoteConfigRefreshJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java index f9fdc5bc1f..b9a2a9a7f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java @@ -14,6 +14,7 @@ public final class RemoteConfigValues extends SignalStoreValues { private static final String CURRENT_CONFIG = "remote_config"; private static final String PENDING_CONFIG = "pending_remote_config"; private static final String LAST_FETCH_TIME = "remote_config_last_fetch_time"; + private static final String ETAG = "etag"; RemoteConfigValues(@NonNull KeyValueStore store) { super(store); @@ -51,4 +52,12 @@ public final class RemoteConfigValues extends SignalStoreValues { public void setLastFetchTime(long time) { putLong(LAST_FETCH_TIME, time); } + + public String getETag() { + return getString(ETAG, ""); + } + + public void setETag(String etag) { + putString(ETAG, etag); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt index 0fe6f008c7..fb9c87826e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt @@ -151,6 +151,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) { val pin: PinValues get() = instance!!.pinValues + @JvmStatic + @get:JvmName("remoteConfig") val remoteConfig: RemoteConfigValues get() = instance!!.remoteConfigValues diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigApi.kt index a2d8b444a9..af8d7c91af 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigApi.kt @@ -12,6 +12,7 @@ import org.whispersystems.signalservice.internal.push.PushServiceSocket import org.whispersystems.signalservice.internal.util.JsonUtil import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage import org.whispersystems.signalservice.internal.websocket.WebsocketResponse +import java.util.Locale /** * Remote configuration is a list of namespaced keys that clients may use for consistent configuration or behavior. @@ -23,19 +24,22 @@ class RemoteConfigApi(val authWebSocket: SignalWebSocket.AuthenticatedWebSocket, /** * Get remote config data from the server. * - * GET /v1/config + * GET /v2/config * - 200: Success + * - 304: No changes since the last fetch + * - 401: Requires authentication */ - fun getRemoteConfig(): NetworkResult { - val request = WebSocketRequestMessage.get("/v1/config") + fun getRemoteConfig(eTag: String = ""): NetworkResult { + val headers = if (eTag.isNotEmpty()) mapOf("If-None-Match" to eTag) else mapOf() + val request = WebSocketRequestMessage.get("/v2/config", headers = headers) return NetworkResult.fromWebSocketRequest(signalWebSocket = authWebSocket, request = request, webSocketResponseConverter = RemoteConfigResultWebSocketResponseConverter()) - .fallback { + .fallback(predicate = { it is NetworkResult.StatusCodeError && it.code != 304 }) { NetworkResult.fromFetch { val response = pushServiceSocket.getRemoteConfig() - val transformed = response.config.associate { it.name to (it.value ?: it.isEnabled) } + val transformed = response.config.map { it.key to (it.value.lowercase(Locale.getDefault()).toBooleanStrictOrNull() ?: it.value) }.toMap() RemoteConfigResult( config = transformed, - serverEpochTimeMilliseconds = response.serverEpochTime.takeIf { it > 0 }?.times(1000) ?: System.currentTimeMillis() + serverEpochTimeMilliseconds = response.serverEpochTime ) } } @@ -51,12 +55,13 @@ class RemoteConfigApi(val authWebSocket: SignalWebSocket.AuthenticatedWebSocket, response.toStatusCodeError() } else { val remoteConfigResponse = JsonUtil.fromJson(response.body, RemoteConfigResponse::class.java) - val transformed = remoteConfigResponse.config.associate { it.name to (it.value ?: it.isEnabled) } + val transformed = remoteConfigResponse.config.map { it.key to (it.value.lowercase(Locale.getDefault()).toBooleanStrictOrNull() ?: it.value) }.toMap() NetworkResult.Success( RemoteConfigResult( config = transformed, - serverEpochTimeMilliseconds = response.getHeader(SignalWebSocket.SERVER_DELIVERED_TIMESTAMP_HEADER).toLongOrNull() ?: System.currentTimeMillis() + serverEpochTimeMilliseconds = response.getHeader(SignalWebSocket.SERVER_DELIVERED_TIMESTAMP_HEADER).toLongOrNull() ?: System.currentTimeMillis(), + eTag = response.headers["etag"] ) ) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.java deleted file mode 100644 index 1f28fc990a..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.api.remoteconfig; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -import javax.annotation.Nullable; - -public class RemoteConfigResponse { - @JsonProperty - private List config; - - @JsonProperty - private long serverEpochTime; - - public List getConfig() { - return config; - } - - public long getServerEpochTime() { - return serverEpochTime; - } - - public static class Config { - @JsonProperty - private String name; - - @JsonProperty - private boolean enabled; - - @JsonProperty - private String value; - - public String getName() { - return name; - } - - public boolean isEnabled() { - return enabled; - } - - public @Nullable String getValue() { - return value; - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.kt new file mode 100644 index 0000000000..a4aa95ffbf --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResponse.kt @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.api.remoteconfig + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response class used in /v2/config. [serverEpochTime] should only be used in REST calls. + */ +data class RemoteConfigResponse( + @JsonProperty + val config: Map = emptyMap(), + var serverEpochTime: Long = 0 +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResult.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResult.kt index 8c9a71091b..992b2298f9 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResult.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/remoteconfig/RemoteConfigResult.kt @@ -7,5 +7,6 @@ package org.whispersystems.signalservice.api.remoteconfig data class RemoteConfigResult( val config: Map, - val serverEpochTimeMilliseconds: Long + val serverEpochTimeMilliseconds: Long, + val eTag: String? = "" ) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 2917c0f718..c27afd3202 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -138,6 +138,7 @@ import okhttp3.Call; import okhttp3.ConnectionPool; import okhttp3.ConnectionSpec; import okhttp3.Dns; +import okhttp3.Headers; import okhttp3.HttpUrl; import okhttp3.Interceptor; import okhttp3.MediaType; @@ -178,6 +179,8 @@ public class PushServiceSocket { private static final String VERIFICATION_SESSION_PATH = "/v1/verification/session"; private static final String VERIFICATION_CODE_PATH = "/v1/verification/session/%s/code"; + private static final String REMOTE_CONFIG = "/v2/config"; + private static final String REGISTRATION_PATH = "/v1/registration"; private static final String BACKUP_AUTH_CHECK_V2 = "/v2/svr/auth/check"; @@ -541,8 +544,11 @@ public class PushServiceSocket { } public RemoteConfigResponse getRemoteConfig() throws IOException { - String response = makeServiceRequest("/v1/config", "GET", null); - return JsonUtil.fromJson(response, RemoteConfigResponse.class); + try (Response response = makeServiceRequest(REMOTE_CONFIG, "GET", jsonRequestBody(null), NO_HEADERS, NO_HANDLER, SealedSenderAccess.NONE, false)) { + RemoteConfigResponse remoteConfigResponse = JsonUtil.fromJson(readBodyString(response), RemoteConfigResponse.class); + remoteConfigResponse.setServerEpochTime(response.headers().get("X-Signal-Timestamp") != null ? Long.parseLong(response.headers().get("X-Signal-Timestamp")) : System.currentTimeMillis()); + return remoteConfigResponse; + } } public void cancelInFlightRequests() {