From 1c89168301d2288c6e4433a4ec60bbca77a85033 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 20 Oct 2022 14:02:22 -0700 Subject: [PATCH] Phased rollout by country code; starting w/ desktop.stories remote flag --- ts/RemoteConfig.ts | 89 +++++++++++++++++++++++ ts/state/selectors/items.ts | 54 +++++++++++--- ts/test-both/RemoteConfig_test.ts | 97 ++++++++++++++++++++++++++ ts/test-both/types/PhoneNumber_test.ts | 24 +++++++ ts/textsecure/MessageReceiver.ts | 2 +- ts/types/PhoneNumber.ts | 23 ++++++ ts/types/Stories.ts | 14 ---- ts/util/createIPCEvents.tsx | 2 +- ts/util/sendStoryMessage.ts | 3 +- ts/util/stories.ts | 35 ++++++++++ 10 files changed, 318 insertions(+), 25 deletions(-) create mode 100644 ts/test-both/RemoteConfig_test.ts create mode 100644 ts/test-both/types/PhoneNumber_test.ts create mode 100644 ts/util/stories.ts diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 7ca7f0a0f3..efb6ea26a0 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -5,6 +5,12 @@ import { get, throttle } from 'lodash'; import type { WebAPIType } from './textsecure/WebAPI'; import * as log from './logging/log'; +import type { UUIDStringType } from './types/UUID'; +import { parseIntOrThrow } from './util/parseIntOrThrow'; +import * as Bytes from './Bytes'; +import { hash, uuidToBytes } from './Crypto'; +import { HashType } from './types/Crypto'; +import { getCountryCode } from './types/PhoneNumber'; export type ConfigKeyType = | 'desktop.announcementGroup' @@ -135,3 +141,86 @@ export function isEnabled(name: ConfigKeyType): boolean { export function getValue(name: ConfigKeyType): string | undefined { return get(config, [name, 'value'], undefined); } + +// See isRemoteConfigBucketEnabled in selectors/items.ts +export function isBucketValueEnabled( + name: ConfigKeyType, + e164: string | undefined, + uuid: UUIDStringType | undefined +): boolean { + return innerIsBucketValueEnabled(name, getValue(name), e164, uuid); +} + +export function innerIsBucketValueEnabled( + name: ConfigKeyType, + flagValue: unknown, + e164: string | undefined, + uuid: UUIDStringType | undefined +): boolean { + if (e164 == null || uuid == null) { + return false; + } + + const countryCode = getCountryCode(e164); + if (countryCode == null) { + return false; + } + + if (typeof flagValue !== 'string') { + return false; + } + + const remoteConfigValue = getCountryCodeValue(countryCode, flagValue, name); + if (remoteConfigValue == null) { + return false; + } + + const bucketValue = getBucketValue(uuid, name); + return bucketValue < remoteConfigValue; +} + +export function getCountryCodeValue( + countryCode: number, + flagValue: string, + flagName: string +): number | undefined { + const logId = `getCountryCodeValue/${flagName}`; + if (flagValue.length === 0) { + return undefined; + } + + const countryCodeString = countryCode.toString(); + const items = flagValue.split(','); + + let wildcard: number | undefined; + for (const item of items) { + const [code, value] = item.split(':'); + if (code == null || value == null) { + log.warn(`${logId}: '${code}:${value}' entry was invalid`); + continue; + } + + const parsedValue = parseIntOrThrow( + value, + `${logId}: Country code '${code}' had an invalid number '${value}'` + ); + if (code === '*') { + wildcard = parsedValue; + } else if (countryCodeString === code) { + return parsedValue; + } + } + + return wildcard; +} + +export function getBucketValue(uuid: UUIDStringType, flagName: string): number { + const hashInput = Bytes.concatenate([ + Bytes.fromString(`${flagName}.`), + uuidToBytes(uuid), + ]); + const hashResult = hash(HashType.size256, hashInput); + const buffer = Buffer.from(hashResult.slice(0, 8)); + + return Number(buffer.readBigUint64BE() % 1_000_000n); +} diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 19c3a11442..e6e36f4405 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -5,17 +5,19 @@ import { createSelector } from 'reselect'; import { isInteger } from 'lodash'; import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer'; +import { innerIsBucketValueEnabled } from '../../RemoteConfig'; import type { ConfigKeyType, ConfigMapType } from '../../RemoteConfig'; - import type { StateType } from '../reducer'; import type { ItemsStateType } from '../ducks/items'; import type { ConversationColorType, CustomColorType, } from '../../types/Colors'; +import type { UUIDStringType } from '../../types/UUID'; import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors'; import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji'; import { isBeta } from '../../util/version'; +import { getUserNumber, getUserACI } from './user'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -53,6 +55,17 @@ const isRemoteConfigFlagEnabled = ( key: ConfigKeyType ): boolean => Boolean(config[key]?.enabled); +// See isBucketValueEnabled in RemoteConfig.ts +const isRemoteConfigBucketEnabled = ( + config: Readonly, + name: ConfigKeyType, + e164: string | undefined, + uuid: UUIDStringType | undefined +): boolean => { + const flagValue = config[name]?.value; + return innerIsBucketValueEnabled(name, flagValue, e164, uuid); +}; + const getRemoteConfig = createSelector( getItems, (state: ItemsStateType): ConfigMapType => state.remoteConfig || {} @@ -64,16 +77,41 @@ export const getUsernamesEnabled = createSelector( isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames') ); -// Note: types/Stories is the other place this check is done +// Note: ts/util/stories is the other place this check is done export const getStoriesEnabled = createSelector( getItems, getRemoteConfig, - (state: ItemsStateType, remoteConfig: ConfigMapType): boolean => - !state.hasStoriesDisabled && - (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser') || - isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories') || - (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories.beta') && - isBeta(window.getVersion()))) + getUserNumber, + getUserACI, + ( + state: ItemsStateType, + remoteConfig: ConfigMapType, + e164: string | undefined, + aci: UUIDStringType | undefined + ): boolean => { + if (state.hasStoriesDisabled) { + return false; + } + + if ( + isRemoteConfigBucketEnabled(remoteConfig, 'desktop.stories', e164, aci) + ) { + return true; + } + + if (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser')) { + return true; + } + + if ( + isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories.beta') && + isBeta(window.getVersion()) + ) { + return true; + } + + return false; + } ); export const getDefaultConversationColor = createSelector( diff --git a/ts/test-both/RemoteConfig_test.ts b/ts/test-both/RemoteConfig_test.ts new file mode 100644 index 0000000000..df761c79a5 --- /dev/null +++ b/ts/test-both/RemoteConfig_test.ts @@ -0,0 +1,97 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { + getCountryCodeValue, + getBucketValue, + innerIsBucketValueEnabled, +} from '../RemoteConfig'; + +describe('RemoteConfig', () => { + const uuid = '15b9729c-51ea-4ddb-b516-652befe78062'; + + describe('#innerIsBucketValueEnabled', () => { + // Note: bucketValue is 497941 for 'desktop.stories' key + + it('returns true for 100% wildcard', () => { + assert.strictEqual( + innerIsBucketValueEnabled( + 'desktop.stories', + '*:1000000', + '+12125550000', + uuid + ), + true + ); + }); + + it('returns true for 50% on country code 1', () => { + assert.strictEqual( + innerIsBucketValueEnabled( + 'desktop.stories', + '1:500000', + '+12125550000', + uuid + ), + true + ); + }); + + it('returns false for 40% on country code 1', () => { + assert.strictEqual( + innerIsBucketValueEnabled( + 'desktop.stories', + '1:400000', + '+12125550000', + uuid + ), + false + ); + }); + }); + + describe('#getCountryCodeValue', () => { + it('returns undefined for empty value', () => { + assert.strictEqual(getCountryCodeValue(1, '', 'flagName'), undefined); + }); + + it('throws for malformed flag', () => { + assert.throws( + () => getCountryCodeValue(1, 'hi:::', 'flagName'), + "invalid number ''" + ); + }); + + it('throws for non-integer value', () => { + assert.throws( + () => getCountryCodeValue(1, '1:cd', 'flagName'), + "invalid number 'cd'" + ); + }); + + it('returns wildcard value if no other codes', () => { + assert.strictEqual(getCountryCodeValue(1, '*:56,2:74', 'flagName'), 56); + }); + + it('returns value for specific codes, instead of wildcard', () => { + assert.strictEqual(getCountryCodeValue(1, '*:56,1:74', 'flagName'), 74); + }); + + it('returns undefined if no wildcard or specific value', () => { + assert.strictEqual( + getCountryCodeValue(1, '2:56,3:74', 'flagName'), + undefined + ); + }); + }); + + describe('#getBucketValue', () => { + it('returns undefined for empty value', () => { + const flagName = 'research.megaphone.1'; + + assert.strictEqual(getBucketValue(uuid, flagName), 243315); + }); + }); +}); diff --git a/ts/test-both/types/PhoneNumber_test.ts b/ts/test-both/types/PhoneNumber_test.ts new file mode 100644 index 0000000000..aa22c3993c --- /dev/null +++ b/ts/test-both/types/PhoneNumber_test.ts @@ -0,0 +1,24 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { getCountryCode } from '../../types/PhoneNumber'; + +describe('types/PhoneNumber', () => { + describe('#getCountryCode', () => { + it('returns expected country codes', () => { + assert.strictEqual(getCountryCode('+12125550000'), 1, 'United States'); + assert.strictEqual(getCountryCode('+442012341234'), 44, 'United Kingdom'); + assert.strictEqual(getCountryCode('+37060112345'), 370, 'Lithuania'); + }); + + it('returns undefined for missing phone number', () => { + assert.strictEqual(getCountryCode(undefined), undefined); + }); + + it('returns undefined for invalid phone number', () => { + assert.strictEqual(getCountryCode('+2343d23'), undefined); + }); + }); +}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index e58084e6d9..1dc23b9fbd 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -115,7 +115,7 @@ import { generateBlurHash } from '../util/generateBlurHash'; import { TEXT_ATTACHMENT } from '../types/MIME'; import type { SendTypesType } from '../util/handleMessageSend'; import { isConversationAccepted } from '../util/isConversationAccepted'; -import { getStoriesBlocked } from '../types/Stories'; +import { getStoriesBlocked } from '../util/stories'; const GROUPV1_ID_LENGTH = 16; const GROUPV2_ID_LENGTH = 32; diff --git a/ts/types/PhoneNumber.ts b/ts/types/PhoneNumber.ts index d58c624c2b..72ca1a5fa0 100644 --- a/ts/types/PhoneNumber.ts +++ b/ts/types/PhoneNumber.ts @@ -3,6 +3,8 @@ import memoizee from 'memoizee'; import { instance, PhoneNumberFormat } from '../util/libphonenumberInstance'; +import * as log from '../logging/log'; +import * as Errors from './errors'; function _format( phoneNumber: string, @@ -25,6 +27,27 @@ function _format( } } +export function getCountryCode( + phoneNumber: string | undefined +): number | undefined { + try { + if (phoneNumber == null) { + return undefined; + } + if (!isValidNumber(phoneNumber)) { + return undefined; + } + + return instance.parse(phoneNumber).getCountryCode(); + } catch (error) { + const errorText = Errors.toLogFormat(error); + log.info( + `getCountryCode: Failed to get country code from ${phoneNumber}: ${errorText}` + ); + return undefined; + } +} + export function isValidNumber( phoneNumber: string, options?: { diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index d5850af94e..a5208cc363 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -9,8 +9,6 @@ import type { ReadStatus } from '../messages/MessageReadStatus'; import type { SendStatus } from '../messages/MessageSendState'; import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; import type { UUIDStringType } from './UUID'; -import { isEnabled } from '../RemoteConfig'; -import { isBeta } from '../util/version'; export type ReplyType = { author: Pick< @@ -159,15 +157,3 @@ export enum StorySendMode { Always = 'Always', Never = 'Never', } - -// Note: selectors/items is the other place this check is done -export const getStoriesAvailable = (): boolean => - isEnabled('desktop.stories') || - isEnabled('desktop.internalUser') || - (isEnabled('desktop.stories.beta') && isBeta(window.getVersion())); - -export const getStoriesDisabled = (): boolean => - window.Events.getHasStoriesDisabled(); - -export const getStoriesBlocked = (): boolean => - !getStoriesAvailable() || getStoriesDisabled(); diff --git a/ts/util/createIPCEvents.tsx b/ts/util/createIPCEvents.tsx index 65af476d4a..511c0ec616 100644 --- a/ts/util/createIPCEvents.tsx +++ b/ts/util/createIPCEvents.tsx @@ -5,7 +5,7 @@ import { webFrame } from 'electron'; import type { AudioDevice } from 'ringrtc'; import * as React from 'react'; import { noop } from 'lodash'; -import { getStoriesAvailable } from '../types/Stories'; +import { getStoriesAvailable } from './stories'; import type { ZoomFactorType } from '../types/Storage.d'; import type { diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 50152cf8ff..36d979efff 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -8,7 +8,8 @@ import type { UUIDStringType } from '../types/UUID'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; import { DAY, SECOND } from './durations'; -import { getStoriesBlocked, MY_STORIES_ID } from '../types/Stories'; +import { MY_STORIES_ID } from '../types/Stories'; +import { getStoriesBlocked } from './stories'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SeenStatus } from '../MessageSeenStatus'; import { SendStatus } from '../messages/MessageSendState'; diff --git a/ts/util/stories.ts b/ts/util/stories.ts new file mode 100644 index 0000000000..9131c7dd4c --- /dev/null +++ b/ts/util/stories.ts @@ -0,0 +1,35 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isBucketValueEnabled, isEnabled } from '../RemoteConfig'; +import { UUIDKind } from '../types/UUID'; +import { isBeta } from './version'; + +// Note: selectors/items is the other place this check is done +export const getStoriesAvailable = (): boolean => { + if ( + isBucketValueEnabled( + 'desktop.stories', + window.textsecure.storage.user.getNumber(), + window.textsecure.storage.user.getUuid(UUIDKind.ACI)?.toString() + ) + ) { + return true; + } + + if (isEnabled('desktop.internalUser')) { + return true; + } + + if (isEnabled('desktop.stories.beta') && isBeta(window.getVersion())) { + return true; + } + + return false; +}; + +export const getStoriesDisabled = (): boolean => + window.Events.getHasStoriesDisabled(); + +export const getStoriesBlocked = (): boolean => + !getStoriesAvailable() || getStoriesDisabled();