From 2784285d47ab2790c1998519f2f4f92d09bb9a94 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 8 Sep 2020 18:03:56 -0400 Subject: [PATCH] Add support for fetching remote deprecation. --- .../securesms/ApplicationContext.java | 10 +++ .../reminder/ExpiredBuildReminder.java | 4 +- .../reminder/OutdatedBuildReminder.java | 9 +- .../thoughtcrime/securesms/jobs/SendJob.java | 5 +- .../keyvalue/MiscellaneousValues.java | 9 ++ .../linkpreview/LinkPreviewUtil.java | 23 +---- ...DeprecatedClientPreventionInterceptor.java | 41 +++++++++ .../RemoteDeprecationDetectorInterceptor.java | 31 +++++++ .../net/StandardUserAgentInterceptor.java | 2 +- .../push/SignalServiceNetworkAccess.java | 5 +- .../securesms/util/DateUtils.java | 34 +++++++ .../securesms/util/FeatureFlags.java | 31 +++++-- .../securesms/util/RemoteDeprecation.java | 89 +++++++++++++++++++ .../securesms/util/SemanticVersion.java | 70 +++++++++++++++ .../org/thoughtcrime/securesms/util/Util.java | 25 +++++- .../securesms/testutil/EmptyLogger.java | 26 ++++++ ...xpirationTest_getTimeUntilDeprecation.java | 88 ++++++++++++++++++ .../util/SemanticVersionTest_compareTo.java | 46 ++++++++++ .../util/SemanticVersionTest_parse.java | 41 +++++++++ .../DeprecatedVersionException.java | 4 + .../internal/push/PushServiceSocket.java | 5 ++ 21 files changed, 559 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/testutil/EmptyLogger.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/RemoteExpirationTest_getTimeUntilDeprecation.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_compareTo.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_parse.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/DeprecatedVersionException.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b6b7ffa05..fbb8a4e80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; @@ -70,6 +71,7 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; import org.webrtc.voiceengine.WebRtcAudioManager; @@ -157,6 +159,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi KeyCachingService.onAppForegrounded(this); ApplicationDependencies.getFrameRateTracker().begin(); ApplicationDependencies.getMegaphoneRepository().onAppForegrounded(); + checkBuildExpiration(); } @Override @@ -192,6 +195,13 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi return persistentLogger; } + public void checkBuildExpiration() { + if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Build expired!"); + SignalStore.misc().markDeprecated(); + } + } + private void initializeSecurityProvider() { try { Class.forName("org.signal.aesgcmprovider.AesGcmCipher"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java index b418155ed..5a3b911d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.components.reminder; import android.content.Context; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.PlayStoreUtil; -import org.thoughtcrime.securesms.util.Util; public class ExpiredBuildReminder extends Reminder { @@ -20,7 +20,7 @@ public class ExpiredBuildReminder extends Reminder { } public static boolean isEligible() { - return Util.getDaysTillBuildExpiry() <= 0; + return SignalStore.misc().isClientDeprecated(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java index 28477d003..3bcbecb5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java @@ -6,6 +6,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.Util; +import java.util.concurrent.TimeUnit; + public class OutdatedBuildReminder extends Reminder { public OutdatedBuildReminder(final Context context) { @@ -15,7 +17,7 @@ public class OutdatedBuildReminder extends Reminder { } private static CharSequence getPluralsText(final Context context) { - int days = Util.getDaysTillBuildExpiry() - 1; + int days = getDaysUntilExpiry() - 1; if (days == 0) { return context.getString(R.string.reminder_header_outdated_build_details_today); } @@ -28,7 +30,10 @@ public class OutdatedBuildReminder extends Reminder { } public static boolean isEligible() { - return Util.getDaysTillBuildExpiry() <= 10; + return getDaysUntilExpiry() <= 10; } + private static int getDaysUntilExpiry() { + return (int) TimeUnit.MILLISECONDS.toDays(Util.getTimeUntilBuildExpiry()); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java index ae773ef7e..1ce3aee3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java @@ -8,9 +8,8 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.Util; import java.util.List; @@ -26,7 +25,7 @@ public abstract class SendJob extends BaseJob { @Override public final void onRun() throws Exception { - if (Util.getDaysTillBuildExpiry() <= 0) { + if (SignalStore.misc().isClientDeprecated()) { throw new TextSecureExpiredException(String.format("TextSecure expired (build %d, now %d)", BuildConfig.BUILD_TIMESTAMP, System.currentTimeMillis())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index 58df4c97d..975a114ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -8,6 +8,7 @@ public final class MiscellaneousValues extends SignalStoreValues { private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"; private static final String LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time"; private static final String USERNAME_SHOW_REMINDER = "username.show.reminder"; + private static final String CLIENT_DEPRECATED = "misc.client_deprecated"; MiscellaneousValues(@NonNull KeyValueStore store) { super(store); @@ -45,4 +46,12 @@ public final class MiscellaneousValues extends SignalStoreValues { public boolean shouldShowUsernameReminder() { return getBoolean(USERNAME_SHOW_REMINDER, true); } + + public boolean isClientDeprecated() { + return getBoolean(CLIENT_DEPRECATED, false); + } + + public void markDeprecated() { + putBoolean(CLIENT_DEPRECATED, true); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java index 57274988b..4b47c4cf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java @@ -17,6 +17,7 @@ import com.google.android.collect.Sets; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.stickers.StickerUrl; +import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.OptionalUtil; @@ -203,18 +204,11 @@ public final class LinkPreviewUtil { @SuppressLint("ObsoleteSdkInt") public long getDate() { - SimpleDateFormat format; - if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); - } else { - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); - } - return Stream.of(values.get(KEY_PUBLISHED_TIME_1), values.get(KEY_PUBLISHED_TIME_2), values.get(KEY_MODIFIED_TIME_1), values.get(KEY_MODIFIED_TIME_2)) - .map(dateString -> parseDate(format, dateString)) + .map(DateUtils::parseIso8601) .filter(time -> time > 0) .findFirst() .orElse(0L); @@ -223,19 +217,6 @@ public final class LinkPreviewUtil { public @NonNull Optional getDescription() { return OptionalUtil.absentIfEmpty(values.get(KEY_DESCRIPTION_URL)); } - - private static long parseDate(DateFormat dateFormat, String dateString) { - if (Util.isEmpty(dateString)) { - return 0; - } - - try { - return dateFormat.parse(dateString).getTime(); - } catch (ParseException e) { - Log.w(TAG, "Failed to parse date.", e); - return 0; - } - } } public interface HtmlDecoder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java new file mode 100644 index 000000000..cadf5427b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Disallows network requests when your client has been deprecated. When the client is deprecated, + * we simply fake a 499 response. + */ +public final class DeprecatedClientPreventionInterceptor implements Interceptor { + + private static final String TAG = Log.tag(DeprecatedClientPreventionInterceptor.class); + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + if (SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Preventing request because client is deprecated."); + return new Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .receivedResponseAtMillis(System.currentTimeMillis()) + .message("") + .body(ResponseBody.create(null, "")) + .code(499) + .build(); + } else { + return chain.proceed(chain.request()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java new file mode 100644 index 000000000..80258eb81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Response; + +/** + * Marks the client as remotely-deprecated when it receives a 499 response. + */ +public final class RemoteDeprecationDetectorInterceptor implements Interceptor { + + private static final String TAG = Log.tag(RemoteDeprecationDetectorInterceptor.class); + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + Response response = chain.proceed(chain.request()); + + if (response.code() == 499 && !SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Received 499. Client version is deprecated."); + SignalStore.misc().markDeprecated(); + } + + return response; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java index 0150b46a9..7b17fa1a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java @@ -10,6 +10,6 @@ import org.thoughtcrime.securesms.BuildConfig; public class StandardUserAgentInterceptor extends UserAgentInterceptor { public StandardUserAgentInterceptor() { - super("Signal-Android " + BuildConfig.VERSION_NAME + " (API " + Build.VERSION.SDK_INT + ")"); + super("Signal-Android/" + BuildConfig.VERSION_NAME + " Android/" + Build.VERSION.SDK_INT); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java index d9d4e2146..6942dce11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java +++ b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java @@ -7,6 +7,8 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.net.CustomDns; +import org.thoughtcrime.securesms.net.RemoteDeprecationDetectorInterceptor; +import org.thoughtcrime.securesms.net.DeprecatedClientPreventionInterceptor; import org.thoughtcrime.securesms.net.SequentialDns; import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; import org.thoughtcrime.securesms.util.Base64; @@ -21,6 +23,7 @@ import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl; import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -161,7 +164,7 @@ public class SignalServiceNetworkAccess { final SignalStorageUrl omanGoogleStorage = new SignalStorageUrl("https://www.google.com.om/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); final SignalStorageUrl qatarGoogleStorage = new SignalStorageUrl("https://www.google.com.qa/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); - final List interceptors = Collections.singletonList(new StandardUserAgentInterceptor()); + final List interceptors = Arrays.asList(new StandardUserAgentInterceptor(), new RemoteDeprecationDetectorInterceptor(), new DeprecatedClientPreventionInterceptor()); final Optional dns = Optional.of(DNS); final byte[] zkGroupServerPublicParams; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 3e193f302..5e355866a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -16,13 +16,18 @@ */ package org.thoughtcrime.securesms.util; +import android.annotation.SuppressLint; import android.content.Context; +import android.os.Build; import android.text.format.DateFormat; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logging.Log; +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; @@ -170,4 +175,33 @@ public class DateUtils extends android.text.format.DateUtils { private static String getLocalizedPattern(String template, Locale locale) { return DateFormat.getBestDateTimePattern(locale, template); } + + /** + * e.g. 2020-09-04T19:17:51Z + * https://www.iso.org/iso-8601-date-and-time-format.html + * + * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. + * + * @return The timestamp if able to be parsed, otherwise -1. + */ + @SuppressLint("ObsoleteSdkInt") + public static long parseIso8601(@Nullable String date) { + SimpleDateFormat format; + if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); + } else { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); + } + + if (Util.isEmpty(date)) { + return -1; + } + + try { + return format.parse(date).getTime(); + } catch (ParseException e) { + Log.w(TAG, "Failed to parse date.", e); + return -1; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 792d47412..6f2dc9e70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -64,6 +64,7 @@ public final class FeatureFlags { private static final String MENTIONS = "android.mentions"; private static final String VERIFY_V2 = "android.verifyV2"; private static final String PHONE_NUMBER_PRIVACY_VERSION = "android.phoneNumberPrivacyVersion"; + private static final String CLIENT_EXPIRATION = "android.clientExpiration"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -82,7 +83,8 @@ public final class FeatureFlags { INTERNAL_USER, USERNAMES, MENTIONS, - VERIFY_V2 + VERIFY_V2, + CLIENT_EXPIRATION ); /** @@ -107,7 +109,8 @@ public final class FeatureFlags { GROUPS_V2_CREATE_VERSION, GROUPS_V2_JOIN_VERSION, VERIFY_V2, - CDS_VERSION + CDS_VERSION, + CLIENT_EXPIRATION ); /** @@ -280,6 +283,11 @@ public final class FeatureFlags { return getBoolean(VERIFY_V2, false); } + /** The raw client expiration JSON string. */ + public static String clientExpiration() { + return getString(CLIENT_EXPIRATION, null); + } + /** * Whether the user can choose phone number privacy settings, and; * Whether to fetch and store the secondary certificate @@ -463,6 +471,20 @@ public final class FeatureFlags { return defaultValue; } + private static String getString(@NonNull String key, String defaultValue) { + String forced = (String) FORCED_VALUES.get(key); + if (forced != null) { + return forced; + } + + Object remote = REMOTE_VALUES.get(key); + if (remote instanceof String) { + return (String) remote; + } + + return defaultValue; + } + private static Map parseStoredConfig(String stored) { Map parsed = new HashMap<>(); @@ -511,14 +533,11 @@ public final class FeatureFlags { } } - private static final class MissingFlagRequirementError extends Error { - } - @VisibleForTesting static final class UpdateResult { private final Map memory; private final Map disk; - private final Map memoryChanges; + private final Map memoryChanges; UpdateResult(@NonNull Map memory, @NonNull Map disk, @NonNull Map memoryChanges) { this.memory = memory; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java new file mode 100644 index 000000000..864687674 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.logging.Log; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class RemoteDeprecation { + + private static final String TAG = Log.tag(RemoteDeprecation.class); + + private RemoteDeprecation() { } + + /** + * @return The amount of time (in milliseconds) until this client version expires, or -1 if + * there's no pending expiration. + */ + public static long getTimeUntilDeprecation() { + return getTimeUntilDeprecation(FeatureFlags.clientExpiration(), System.currentTimeMillis(), BuildConfig.VERSION_NAME); + } + + /** + * @return The amount of time (in milliseconds) until this client version expires, or -1 if + * there's no pending expiration. + */ + @VisibleForTesting + static long getTimeUntilDeprecation(String json, long currentTime, @NonNull String currentVersion) { + if (Util.isEmpty(json)) { + return -1; + } + + try { + SemanticVersion ourVersion = Objects.requireNonNull(SemanticVersion.parse(currentVersion)); + ClientExpiration[] expirations = JsonUtils.fromJson(json, ClientExpiration[].class); + + ClientExpiration expiration = Stream.of(expirations) + .filter(c -> c.getVersion() != null && c.getExpiration() != -1) + .filter(c -> c.requireVersion().compareTo(ourVersion) > 0) + .sortBy(ClientExpiration::getExpiration) + .findFirst() + .orElse(null); + + if (expiration != null) { + return Math.max(expiration.getExpiration() - currentTime, 0); + } + } catch (IOException e) { + Log.w(TAG, e); + } + + return -1; + } + + private static final class ClientExpiration { + @JsonProperty + private final String minVersion; + + @JsonProperty + private final String iso8601; + + ClientExpiration(@Nullable @JsonProperty("minVersion") String minVersion, + @Nullable @JsonProperty("iso8601") String iso8601) + { + this.minVersion = minVersion; + this.iso8601 = iso8601; + } + + public @Nullable SemanticVersion getVersion() { + return SemanticVersion.parse(minVersion); + } + + public @NonNull SemanticVersion requireVersion() { + return Objects.requireNonNull(getVersion()); + } + + public long getExpiration() { + return DateUtils.parseIso8601(iso8601); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java b/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java new file mode 100644 index 000000000..a34db6b0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.ComparatorCompat; + +import java.util.Comparator; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class SemanticVersion implements Comparable { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^([0-9]+)\\.([0-9]+)\\.([0-9]+)$"); + + private static final Comparator MAJOR_COMPARATOR = (s1, s2) -> Integer.compare(s1.major, s2.major); + private static final Comparator MINOR_COMPARATOR = (s1, s2) -> Integer.compare(s1.minor, s2.minor); + private static final Comparator PATCH_COMPARATOR = (s1, s2) -> Integer.compare(s1.patch, s2.patch); + private static final Comparator COMPARATOR = ComparatorCompat.chain(MAJOR_COMPARATOR) + .thenComparing(MINOR_COMPARATOR) + .thenComparing(PATCH_COMPARATOR); + + private final int major; + private final int minor; + private final int patch; + + public SemanticVersion(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + public static @Nullable SemanticVersion parse(@Nullable String value) { + if (value == null) { + return null; + } + + Matcher matcher = VERSION_PATTERN.matcher(value); + if (Util.isEmpty(value) || !matcher.matches()) { + return null; + } + + int major = Integer.parseInt(matcher.group(1)); + int minor = Integer.parseInt(matcher.group(2)); + int patch = Integer.parseInt(matcher.group(3)); + + return new SemanticVersion(major, minor, patch); + } + + @Override + public int compareTo(SemanticVersion other) { + return COMPARATOR.compare(this, other); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SemanticVersion that = (SemanticVersion) o; + return major == that.major && + minor == that.minor && + patch == that.patch; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor, patch); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 583e0c9d8..cec591636 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -49,6 +49,7 @@ import com.google.i18n.phonenumbers.Phonenumber; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.OutgoingLegacyMmsConnection; import org.whispersystems.libsignal.util.guava.Optional; @@ -77,6 +78,8 @@ import java.util.concurrent.TimeUnit; public class Util { private static final String TAG = Util.class.getSimpleName(); + private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90); + private static volatile Handler handler; public static List asList(T... elements) { @@ -458,9 +461,25 @@ public class Util { return secret; } - public static int getDaysTillBuildExpiry() { - int age = (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP); - return 90 - age; + /** + * @return The amount of time (in ms) until this build of Signal will be considered 'expired'. + * Takes into account both the build age as well as any remote deprecation values. + */ + public static long getTimeUntilBuildExpiry() { + if (SignalStore.misc().isClientDeprecated()) { + return 0; + } + + long buildAge = System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP; + long timeUntilBuildDeprecation = BUILD_LIFESPAN - buildAge; + long timeUntilRemoteDeprecation = RemoteDeprecation.getTimeUntilDeprecation(); + + if (timeUntilRemoteDeprecation != -1) { + long timeUntilDeprecation = Math.min(timeUntilBuildDeprecation, timeUntilRemoteDeprecation); + return Math.max(timeUntilDeprecation, 0); + } else { + return Math.max(timeUntilBuildDeprecation, 0); + } } @TargetApi(VERSION_CODES.LOLLIPOP) diff --git a/app/src/test/java/org/thoughtcrime/securesms/testutil/EmptyLogger.java b/app/src/test/java/org/thoughtcrime/securesms/testutil/EmptyLogger.java new file mode 100644 index 000000000..4475d1947 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/testutil/EmptyLogger.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.testutil; + +import org.thoughtcrime.securesms.logging.Log; + +public class EmptyLogger extends Log.Logger { + @Override + public void v(String tag, String message, Throwable t) { } + + @Override + public void d(String tag, String message, Throwable t) { } + + @Override + public void i(String tag, String message, Throwable t) { } + + @Override + public void w(String tag, String message, Throwable t) { } + + @Override + public void e(String tag, String message, Throwable t) { } + + @Override + public void wtf(String tag, String message, Throwable t) { } + + @Override + public void blockUntilAllWritesFinished() { } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteExpirationTest_getTimeUntilDeprecation.java b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteExpirationTest_getTimeUntilDeprecation.java new file mode 100644 index 000000000..394d655ad --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteExpirationTest_getTimeUntilDeprecation.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.util; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.testutil.EmptyLogger; + +import java.util.Arrays; +import java.util.Collection; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(Parameterized.class) +public class RemoteExpirationTest_getTimeUntilDeprecation { + + private final String json; + private final long currentDate; + private final String currentVersion; + private final long timeUntilExpiration; + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + // Null json, invalid + { null, DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.0", -1 }, + + // Empty json, no expiration + { "[]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.0", -1 }, + + // Badly formatted minVersion, no expiration + { "[ {\"minVersion\": \"1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Badly formatted date, no expiration + { "[ {\"minVersion\": \"1.1.1\", \"iso8601\": \"20-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Missing minVersion, no expiration + { "[ {\"iso8601\": \"20-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Missing date, no expiration + { "[ {\"minVersion\": \"1.1.1\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Missing expiration and date, no expiration + { "[ {} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Invalid inner object, no expiration + { "[ { \"a\": 1 } ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // Invalid json, no expiration + { "[ {", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // We meet the min version, no expiration + { "[ {\"minVersion\": \"1.1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.1", -1 }, + + // We exceed the min version, no expiration + { "[ {\"minVersion\": \"1.1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.2", -1 }, + + // We expire in 1 second + { "[ {\"minVersion\": \"1.1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.0", 1000 }, + + // We have already expired + { "[ {\"minVersion\": \"1.1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:02Z"), "1.1.0", 0 }, + + // Use the closest expiration when multiple ones are listed + { "[ {\"minVersion\": \"1.1.2\", \"iso8601\": \"2020-02-01T00:00:00Z\"}," + + "{\"minVersion\": \"1.1.3\", \"iso8601\": \"2020-03-01T00:00:00Z\"}," + + "{\"minVersion\": \"1.1.1\", \"iso8601\": \"2020-01-01T00:00:01Z\"} ]", DateUtils.parseIso8601("2020-01-01T00:00:00Z"), "1.1.0", 1000 }, + }); + } + + public RemoteExpirationTest_getTimeUntilDeprecation(String json, long currentDate, String currentVersion, long timeUntilExpiration) { + this.json = json; + this.currentDate = currentDate; + this.currentVersion = currentVersion; + this.timeUntilExpiration = timeUntilExpiration; + } + + @BeforeClass + public static void setup() { + Log.initialize(new EmptyLogger()); + } + + @Test + public void getTimeUntilExpiration() { + assertEquals(timeUntilExpiration, RemoteDeprecation.getTimeUntilDeprecation(json, currentDate, currentVersion)); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_compareTo.java b/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_compareTo.java new file mode 100644 index 000000000..27a58061e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_compareTo.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.util; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.Collection; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(Parameterized.class) +public class SemanticVersionTest_compareTo { + + private final SemanticVersion first; + private final SemanticVersion second; + private final int output; + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + { new SemanticVersion(1, 0, 0), new SemanticVersion(0, 1, 0), 1 }, + { new SemanticVersion(1, 0, 0), new SemanticVersion(0, 0, 1), 1 }, + { new SemanticVersion(1, 0, 0), new SemanticVersion(0, 0, 0), 1 }, + { new SemanticVersion(0, 1, 0), new SemanticVersion(0, 0, 1), 1 }, + { new SemanticVersion(0, 1, 0), new SemanticVersion(0, 0, 0), 1 }, + { new SemanticVersion(0, 0, 1), new SemanticVersion(0, 0, 0), 1 }, + { new SemanticVersion(1, 1, 0), new SemanticVersion(1, 0, 0), 1 }, + { new SemanticVersion(1, 1, 1), new SemanticVersion(1, 1, 0), 1 }, + { new SemanticVersion(0, 0, 1), new SemanticVersion(1, 0, 0), -1 }, + { new SemanticVersion(1, 1, 1), new SemanticVersion(1, 1, 1), 0 }, + { new SemanticVersion(0, 0, 0), new SemanticVersion(0, 0, 0), 0 }, + }); + } + + public SemanticVersionTest_compareTo(SemanticVersion first, SemanticVersion second, int output) { + this.first = first; + this.second = second; + this.output = output; + } + + @Test + public void compareTo() { + assertEquals(output, first.compareTo(second)); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_parse.java b/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_parse.java new file mode 100644 index 000000000..203b61611 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/SemanticVersionTest_parse.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.util; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; + +import java.util.Arrays; +import java.util.Collection; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(Parameterized.class) +public class SemanticVersionTest_parse { + + private final String input; + private final SemanticVersion output; + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + { "0.0.0", new SemanticVersion(0, 0, 0)}, + { "1.2.3", new SemanticVersion(1, 2, 3)}, + { "111.222.333", new SemanticVersion(111, 222, 333)}, + { "v1.2.3", null }, + { "1.2.3x", null }, + { "peter.ben.parker", null }, + { "", null} + }); + } + + public SemanticVersionTest_parse(String input, SemanticVersion output) { + this.input = input; + this.output = output; + } + + @Test + public void parse() { + assertEquals(output, SemanticVersion.parse(input)); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/DeprecatedVersionException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/DeprecatedVersionException.java new file mode 100644 index 000000000..1136044f2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/DeprecatedVersionException.java @@ -0,0 +1,4 @@ +package org.whispersystems.signalservice.api.push.exceptions; + +public class DeprecatedVersionException extends NonSuccessfulResponseCodeException { +} 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 11439ad2f..11eade9a2 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 @@ -49,6 +49,7 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.push.exceptions.ContactManifestMismatchException; +import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedException; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; import org.whispersystems.signalservice.api.push.exceptions.NoContentException; @@ -1459,6 +1460,8 @@ public class PushServiceSocket { throw new LockedException(accountLockFailure.length, accountLockFailure.timeRemaining, basicStorageCredentials); + case 499: + throw new DeprecatedVersionException(); } if (responseCode != 200 && responseCode != 204) { @@ -1688,6 +1691,8 @@ public class PushServiceSocket { } case 429: throw new RateLimitException("Rate limit exceeded: " + response.code()); + case 499: + throw new DeprecatedVersionException(); } throw new NonSuccessfulResponseCodeException("Response: " + response);