Skip to content

Commit

Permalink
chore(runway): cherry-pick feat: implement remote feature flag contro…
Browse files Browse the repository at this point in the history
…ller (#12510)

- feat: implement remote feature flag controller (#12427)

<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

<!--
Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change?
2. What is the improvement/solution?
-->

Introduction of `@metamask/remote-feature-flag-controller` library.

Remote feature flag controller manages data flow, retry policy, and
cache expiry.
The controller consumer manages default values, data persistency, and
data distribution.

See

[ADR](https://github.com/MetaMask/decisions/blob/b3094d47a568ac1e076a44fa704c2d29d1b59f35/decisions/wallet-platform/0001-remote-rollout-feature-flags.md)
for a in-depth description

## Technical decisions

### Controller init on `Engine.ts` with feature flags fetching only on
cold app starts.

`@metamask/remote-feature-flag-controller` is only asked to fetch
feature flags after its init in `Engine.ts`. Ensures feature flags are
only fetched on cold app starts.

### Fallback values

Default values are used when remote feature flags are undefined.
The fallback mechanism is implemented by each feature flag selector
`app/selectors/featureFlagsController/<featureFlagName>`

In this PR we include `minimumAppVersion` selector, which manages the LD
feature flag `mobile-minimum-versions`

### One selector per each feature flag

[LD feature flags can be boolean, number, strings on JSON

objects](https://docs.launchdarkly.com/sdk/concepts/flag-types#understanding-flag-types).
We've decided to have each feature flag with its own selector

A feature flag selector contains:
- state selectors for each feature flag value.
- business logic
- defaults for when feature flags values are undefined.
- TS types.
- unit tests and mocked data.

This architecture offers a clear separation between each feature flag
and the logic behind it, allowing easier manipulation.
Code owners are assigned to each feature flag.

### 

## **Related issues**

Fixes: https://github.com/MetaMask/mobile-planning/issues/2054
Fixes: https://github.com/MetaMask/mobile-planning/issues/1975

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding

Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling

guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

---------

Co-authored-by: Nico MASSART <[email protected]>
Co-authored-by: tommasini <[email protected]>
[a8c0783](a8c0783)

Co-authored-by: João Loureiro <[email protected]>
Co-authored-by: Nico MASSART <[email protected]>
Co-authored-by: tommasini <[email protected]>
  • Loading branch information
4 people authored Dec 2, 2024
1 parent e958128 commit 18aca38
Show file tree
Hide file tree
Showing 33 changed files with 670 additions and 227 deletions.
37 changes: 20 additions & 17 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
app/component-library/ @MetaMask/design-system-engineers

# Platform Team
.github/CODEOWNERS @MetaMask/mobile-platform
patches/ @MetaMask/mobile-platform
app/core/Engine/Engine.ts @MetaMask/mobile-platform
app/core/Engine/Engine.test.ts @MetaMask/mobile-platform
app/core/Engine/index.ts @MetaMask/mobile-platform
app/core/Engine/types.ts @MetaMask/mobile-platform
app/core/Analytics/ @MetaMask/mobile-platform
app/util/metrics/ @MetaMask/mobile-platform
app/components/hooks/useMetrics/ @MetaMask/mobile-platform
app/store/migrations/ @MetaMask/mobile-platform
bitrise.yml @MetaMask/mobile-platform
yarn.lock @MetaMask/mobile-platform
ios/Podfile.lock @MetaMask/mobile-platform
.github/CODEOWNERS @MetaMask/mobile-platform
patches/ @MetaMask/mobile-platform
app/core/Engine/Engine.ts @MetaMask/mobile-platform
app/core/Engine/Engine.test.ts @MetaMask/mobile-platform
app/core/Engine/index.ts @MetaMask/mobile-platform
app/core/Engine/types.ts @MetaMask/mobile-platform
app/core/Engine/controllers/RemoteFeatureFlagController/ @MetaMask/mobile-platform
app/core/Analytics/ @MetaMask/mobile-platform
app/util/metrics/ @MetaMask/mobile-platform
app/components/hooks/useMetrics/ @MetaMask/mobile-platform
app/selectors/featureFlagController/* @MetaMask/mobile-platform
app/selectors/featureFlagController/minimumAppVersion/ @MetaMask/mobile-platform
app/store/migrations/ @MetaMask/mobile-platform
bitrise.yml @MetaMask/mobile-platform
yarn.lock @MetaMask/mobile-platform
ios/Podfile.lock @MetaMask/mobile-platform

# Ramps Team
app/components/UI/Ramp/ @MetaMask/ramp
Expand Down Expand Up @@ -53,10 +56,10 @@ app/components/UI/Swaps @MetaMask/swaps-engineers
app/components/Views/Notifications @MetaMask/notifications
app/components/Views/Settings/NotificationsSettings @MetaMask/notifications
app/components/UI/Notifications @MetaMask/notifications
app/reducers/notification @MetaMask/notifications
app/actions/notification @MetaMask/notifications
app/selectors/notification @MetaMask/notifications
app/util/notifications @MetaMask/notifications
app/reducers/notification @MetaMask/notifications
app/actions/notification @MetaMask/notifications
app/selectors/notification @MetaMask/notifications
app/util/notifications @MetaMask/notifications
app/store/util/notifications @MetaMask/notifications

# LavaMoat Team
Expand Down
29 changes: 24 additions & 5 deletions app/components/hooks/MinimumVersions/useMinimumVersions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,21 @@ describe('useMinimumVersions', () => {
jest.clearAllMocks();
(useNavigation as jest.Mock).mockReturnValue(mockNavigation);
});

it('requires update only if automaticSecurityChecksEnabled', () => {
(useSelector as jest.Mock).mockImplementation(() => ({
security: { automaticSecurityChecksEnabled: false },
featureFlags: {
featureFlags: { mobileMinimumVersions: { appMinimumBuild: 100 } },
engine: {
backgroundState: {
RemoteFeatureFlagController: {
remoteFeatureFlags: {
mobileMinimumVersions: {
appMinimumBuild: 100,
appleMinimumOS: 100,
androidMinimumAPIVersion: 100,
},
},
},
},
},
}));

Expand All @@ -54,8 +63,18 @@ describe('useMinimumVersions', () => {
it('requires update only if currentBuildNumber is lower than appMinimumBuild', () => {
(useSelector as jest.Mock).mockImplementation(() => ({
security: { automaticSecurityChecksEnabled: true },
featureFlags: {
featureFlags: { mobileMinimumVersions: { appMinimumBuild: 100 } },
engine: {
backgroundState: {
RemoteFeatureFlagController: {
remoteFeatureFlags: {
mobileMinimumVersions: {
appMinimumBuild: 100,
appleMinimumOS: 100,
androidMinimumAPIVersion: 100,
},
},
},
},
},
}));

Expand Down
14 changes: 6 additions & 8 deletions app/components/hooks/MinimumVersions/useMinimumVersions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ import { createUpdateNeededNavDetails } from '../../UI/UpdateNeeded/UpdateNeeded
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { InteractionManager } from 'react-native';
import { FeatureFlagsState } from '../../../core/redux/slices/featureFlags';
import { SecurityState } from '../../../../app/reducers/security';
import { RootState } from '../../../../app/reducers';
import { SecurityState } from '../../../reducers/security';
import { RootState } from '../../../reducers';
import { selectAppMinimumBuild } from '../../../selectors/featureFlagController/minimumAppVersion';

const useMinimumVersions = () => {
const { automaticSecurityChecksEnabled }: SecurityState = useSelector(
(state: RootState) => state.security,
);
const { featureFlags }: FeatureFlagsState = useSelector(
(state: RootState) => state.featureFlags,
);

const appMinimumBuild = useSelector((state: RootState) => selectAppMinimumBuild(state));
const currentBuildNumber = Number(getBuildNumber());
const navigation = useNavigation();
const shouldTriggerUpdateFlow =
automaticSecurityChecksEnabled &&
featureFlags?.mobileMinimumVersions?.appMinimumBuild > currentBuildNumber;
automaticSecurityChecksEnabled && appMinimumBuild > currentBuildNumber;

useEffect(() => {
if (shouldTriggerUpdateFlow) {
Expand Down
5 changes: 4 additions & 1 deletion app/core/Engine/Engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ jest.mock('../../store', () => ({
jest.mock('../../selectors/smartTransactionsController', () => ({
selectShouldUseSmartTransaction: jest.fn().mockReturnValue(false),
}));

jest.mock('../../selectors/settings', () => ({
selectBasicFunctionalityEnabled: jest.fn().mockReturnValue(true),
}));
describe('Engine', () => {
it('should expose an API', () => {
const engine = Engine.init({});
Expand All @@ -37,6 +39,7 @@ describe('Engine', () => {
expect(engine.context).toHaveProperty('NetworkController');
expect(engine.context).toHaveProperty('PhishingController');
expect(engine.context).toHaveProperty('PreferencesController');
expect(engine.context).toHaveProperty('RemoteFeatureFlagController');
expect(engine.context).toHaveProperty('SignatureController');
expect(engine.context).toHaveProperty('TokenBalancesController');
expect(engine.context).toHaveProperty('TokenRatesController');
Expand Down
43 changes: 30 additions & 13 deletions app/core/Engine/Engine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
import Crypto from 'react-native-quick-crypto';
import { scrypt } from 'react-native-fast-crypto';

import {
AccountTrackerController,
AssetsContractController,
Expand Down Expand Up @@ -153,6 +154,7 @@ import {
} from './controllers/accounts/constants';
import { AccountsControllerMessenger } from '@metamask/accounts-controller';
import { createAccountsController } from './controllers/accounts/utils';
import { createRemoteFeatureFlagController } from './controllers/RemoteFeatureFlagController';
import { captureException } from '@sentry/react-native';
import { lowerCase } from 'lodash';
import {
Expand All @@ -161,6 +163,7 @@ import {
} from '../../core/redux/slices/inpageProvider';
import SmartTransactionsController from '@metamask/smart-transactions-controller';
import { getAllowedSmartTransactionsChainIds } from '../../../app/constants/smartTransactions';
import { selectBasicFunctionalityEnabled } from '../../selectors/settings';
import { selectShouldUseSmartTransaction } from '../../selectors/smartTransactionsController';
import { selectSwapsChainFeatureFlags } from '../../reducers/swaps';
import { SmartTransactionStatuses, ClientId } from '@metamask/smart-transactions-controller/dist/types';
Expand Down Expand Up @@ -264,6 +267,9 @@ export class Engine {
) {
this.controllerMessenger = new ExtendedControllerMessenger();

const isBasicFunctionalityToggleEnabled = () =>
selectBasicFunctionalityEnabled(store.getState());

const approvalController = new ApprovalController({
messenger: this.controllerMessenger.getRestricted({
name: 'ApprovalController',
Expand Down Expand Up @@ -476,6 +482,16 @@ export class Engine {
'https://gas.api.cx.metamask.io/networks/<chain_id>/suggestedGasFees',
});

const remoteFeatureFlagController = createRemoteFeatureFlagController({
state: initialState.RemoteFeatureFlagController,
messenger: this.controllerMessenger.getRestricted({
name: 'RemoteFeatureFlagController',
allowedActions: [],
allowedEvents: [],
}),
disabled: !isBasicFunctionalityToggleEnabled(),
});

const phishingController = new PhishingController({
messenger: this.controllerMessenger.getRestricted({
name: 'PhishingController',
Expand Down Expand Up @@ -924,8 +940,7 @@ export class Engine {
encryptor,
getMnemonic: getPrimaryKeyringMnemonic.bind(this),
getFeatureFlags: () => ({
disableSnaps:
store.getState().settings.basicFunctionalityEnabled === false,
disableSnaps: !isBasicFunctionalityToggleEnabled(),
}),
});

Expand Down Expand Up @@ -1126,7 +1141,7 @@ export class Engine {

return Boolean(
hasProperty(showIncomingTransactions, currentChainId) &&
showIncomingTransactions?.[currentHexChainId],
showIncomingTransactions?.[currentHexChainId],
);
},
updateTransactions: true,
Expand Down Expand Up @@ -1383,6 +1398,7 @@ export class Engine {
GasFeeController: gasFeeController,
ApprovalController: approvalController,
PermissionController: permissionController,
RemoteFeatureFlagController: remoteFeatureFlagController,
SelectedNetworkController: selectedNetworkController,
SignatureController: new SignatureController({
messenger: this.controllerMessenger.getRestricted({
Expand Down Expand Up @@ -1484,7 +1500,7 @@ export class Engine {
(state: NetworkState) => {
if (
state.networksMetadata[state.selectedNetworkClientId].status ===
NetworkStatus.Available &&
NetworkStatus.Available &&
networkController.getNetworkClientById(
networkController?.state.selectedNetworkClientId,
).configuration.chainId !== currentChainId
Expand All @@ -1509,10 +1525,9 @@ export class Engine {
} catch (error) {
console.error(
error,
`Network ID not changed, current chainId: ${
networkController.getNetworkClientById(
networkController?.state.selectedNetworkClientId,
).configuration.chainId
`Network ID not changed, current chainId: ${networkController.getNetworkClientById(
networkController?.state.selectedNetworkClientId,
).configuration.chainId
}`,
);
}
Expand Down Expand Up @@ -1714,7 +1729,7 @@ export class Engine {
const decimalsToShow = (currentCurrency === 'usd' && 2) || undefined;
if (
accountsByChainId?.[toHexadecimal(chainId)]?.[
selectSelectedInternalAccountChecksummedAddress
selectSelectedInternalAccountChecksummedAddress
]
) {
const balanceBN = hexToBN(
Expand Down Expand Up @@ -1751,7 +1766,7 @@ export class Engine {

const tokenBalances =
allTokenBalances?.[selectedInternalAccount.address as Hex]?.[
chainId
chainId
] ?? {};
tokens.forEach(
(item: { address: string; balance?: string; decimals: number }) => {
Expand All @@ -1762,9 +1777,9 @@ export class Engine {
item.balance ||
(item.address in tokenBalances
? renderFromTokenMinimalUnit(
tokenBalances[item.address as Hex],
item.decimals,
)
tokenBalances[item.address as Hex],
item.decimals,
)
: undefined);
const tokenBalanceFiat = balanceToFiatNumber(
// TODO: Fix this by handling or eliminating the undefined case
Expand Down Expand Up @@ -2057,6 +2072,7 @@ export default {
NetworkController,
PreferencesController,
PhishingController,
RemoteFeatureFlagController,
PPOMController,
TokenBalancesController,
TokenRatesController,
Expand Down Expand Up @@ -2102,6 +2118,7 @@ export default {
KeyringController,
NetworkController,
PhishingController,
RemoteFeatureFlagController,
PPOMController,
PreferencesController,
TokenBalancesController,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createRemoteFeatureFlagController } from './utils';

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller';

export interface RemoteFeatureFlagInitParamTypes {
state?: RemoteFeatureFlagControllerState;
messenger: RemoteFeatureFlagControllerMessenger,
disabled: boolean
}

Loading

0 comments on commit 18aca38

Please sign in to comment.