diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index cc1c885adfa..eba1b005878 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; @@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.stickers.BlessedPacks; +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; @@ -133,6 +135,7 @@ public void onCreate() { initializeBlobProvider(); initializeCleanup(); initializeCameraX(); + FeatureFlags.init(); NotificationChannels.create(this); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java index 60dcd9b2646..60fe7f7abf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java @@ -42,7 +42,6 @@ import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ThemeUtil; diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java index 658d25c2e1f..fc6f2d869a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java @@ -113,7 +113,7 @@ public String[] getRecipientStrings() { } private String getRecipientName(Recipient recipient) { - if (FeatureFlags.PROFILE_DISPLAY) return recipient.getDisplayName(context); + if (FeatureFlags.profileDisplay()) return recipient.getDisplayName(context); String name = recipient.toShortString(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java index cadb851a4d1..97c0277228c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java @@ -434,7 +434,7 @@ private void setSummaries(Recipient recipient) { colorPreference.setColors(MaterialColors.CONVERSATION_PALETTE.asConversationColorArray(requireActivity())); colorPreference.setColor(recipient.getColor().toActionBarColor(requireActivity())); - if (FeatureFlags.PROFILE_DISPLAY) { + if (FeatureFlags.profileDisplay()) { aboutPreference.setTitle(recipient.getDisplayName(requireContext())); aboutPreference.setSummary(recipient.resolve().getE164().or("")); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java index 01fc9032eff..e6965067434 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -275,7 +275,7 @@ public void onCreate(Bundle bundle) { byte[] localId; byte[] remoteId; - if (FeatureFlags.UUIDS && recipient.resolve().getUuid().isPresent()) { + if (FeatureFlags.uuids() && recipient.resolve().getUuid().isPresent()) { Log.i(TAG, "Using UUID (version 2)."); version = 2; localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index 7ca54443ca7..99d75bd56d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -61,7 +61,7 @@ public void setText(Recipient recipient, boolean read, @Nullable String suffix) if (recipient.isLocalNumber()) { builder.append(getContext().getString(R.string.note_to_self)); - } else if (!FeatureFlags.PROFILE_DISPLAY && recipient.getName(getContext()) == null && !recipient.getProfileName().isEmpty()) { + } else if (!FeatureFlags.profileDisplay() && recipient.getName(getContext()) == null && !recipient.getProfileName().isEmpty()) { SpannableString profileName = new SpannableString(" (~" + recipient.getProfileName().toString() + ") "); profileName.setSpan(new CenterAlignedRelativeSizeSpan(0.75f), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); profileName.setSpan(new TypefaceSpan("sans-serif-light"), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java index cc613247984..217761a5e40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java @@ -370,7 +370,7 @@ private void setPersonInfo(final @NonNull Recipient recipient) { .diskCacheStrategy(DiskCacheStrategy.ALL) .into(this.photo); - if (FeatureFlags.PROFILE_DISPLAY) { + if (FeatureFlags.profileDisplay()) { this.name.setText(recipient.getDisplayName(getContext())); if (recipient.getE164().isPresent()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 8c7065c032b..6a2f877cd58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -145,15 +145,15 @@ private List getFilteredResults() { cursorList.addAll(getContactsCursors()); } - if (FeatureFlags.USERNAMES && NumberUtil.isVisuallyValidNumberOrEmail(filter)) { + if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(filter)) { cursorList.add(getPhoneNumberSearchHeaderCursor()); cursorList.add(getNewNumberCursor()); - } else if (!FeatureFlags.USERNAMES && NumberUtil.isValidSmsOrEmail(filter)){ + } else if (!FeatureFlags.usernames() && NumberUtil.isValidSmsOrEmail(filter)){ cursorList.add(getContactsHeaderCursor()); cursorList.add(getNewNumberCursor()); } - if (FeatureFlags.USERNAMES && UsernameUtil.isValidUsernameForSearch(filter)) { + if (FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(filter)) { cursorList.add(getUsernameSearchHeaderCursor()); cursorList.add(getUsernameSearchCursor()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index 982cb6dcc79..3e564a93056 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -17,14 +17,14 @@ public class DirectoryHelper { @WorkerThread public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException { - if (FeatureFlags.UUIDS) { + if (FeatureFlags.uuids()) { // TODO [greyson] Create a DirectoryHelperV2 when appropriate. DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); } else { DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); } - if (FeatureFlags.STORAGE_SERVICE) { + if (FeatureFlags.storageService()) { ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } } @@ -34,14 +34,14 @@ public static RegisteredState refreshDirectoryFor(@NonNull Context context, @Non RegisteredState originalRegisteredState = recipient.resolve().getRegistered(); RegisteredState newRegisteredState = null; - if (FeatureFlags.UUIDS) { + if (FeatureFlags.uuids()) { // TODO [greyson] Create a DirectoryHelperV2 when appropriate. newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers); } else { newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers); } - if (FeatureFlags.STORAGE_SERVICE && newRegisteredState != originalRegisteredState) { + if (FeatureFlags.storageService() && newRegisteredState != originalRegisteredState) { ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index ecde995c877..d8e4f3a4d1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -2014,7 +2014,7 @@ private void setBlockedUserState(Recipient recipient, boolean isSecureText, bool } private void setGroupShareProfileReminder(@NonNull Recipient recipient) { - if (!FeatureFlags.MESSAGE_REQUESTS && recipient.isPushGroup() && !recipient.isProfileSharing()) { + if (!FeatureFlags.messageRequests() && recipient.isPushGroup() && !recipient.isProfileSharing()) { groupShareProfileView.get().setRecipient(recipient); groupShareProfileView.get().setVisibility(View.VISIBLE); } else if (groupShareProfileView.resolved()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 874af3a6fe3..28f6f129b8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -713,7 +713,7 @@ public void onLoadFinished(@NonNull Loader cursorLoader, Cursor cursor) setLastSeen(loader.getLastSeen()); } - if (FeatureFlags.MESSAGE_REQUESTS) { + if (FeatureFlags.messageRequests()) { if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isProfileSharing() && !recipient.get().isBlocked() && recipient.get().isRegistered()) { listener.onMessageRequest(); } else { @@ -994,8 +994,8 @@ public void onItemLongClick(View maskTarget, MessageRecord messageRecord) { if (actionMode != null) return; - if (FeatureFlags.REACTION_SENDING && - messageRecord.isSecure() && + if (FeatureFlags.reactionSending() && + messageRecord.isSecure() && ((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty()) { isReacting = true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index d1ce3e1250c..d9aff3e6ad8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -960,7 +960,7 @@ private boolean shouldInterceptClicks(MessageRecord messageRecord) { private void setGroupMessageStatus(MessageRecord messageRecord, Recipient recipient) { if (groupThread && !messageRecord.isOutgoing()) { - if (FeatureFlags.PROFILE_DISPLAY) { + if (FeatureFlags.profileDisplay()) { this.groupSender.setText(recipient.getDisplayName(getContext())); this.groupSenderProfileName.setVisibility(View.GONE); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index 7bd5335adfc..ea3e6c5e27d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -126,7 +126,7 @@ private void setComposeTitle() { } private void setRecipientTitle(Recipient recipient) { - if (FeatureFlags.PROFILE_DISPLAY) { + if (FeatureFlags.profileDisplay()) { if (recipient.isGroup()) setGroupRecipientTitle(recipient); else if (recipient.isLocalNumber()) setSelfTitle(); else setIndividualRecipientTitle(recipient); @@ -166,7 +166,7 @@ private void setContactRecipientTitle(Recipient recipient) { private void setGroupRecipientTitle(Recipient recipient) { String localNumber = TextSecurePreferences.getLocalNumber(getContext()); - if (FeatureFlags.PROFILE_DISPLAY) { + if (FeatureFlags.profileDisplay()) { this.title.setText(recipient.getDisplayName(getContext())); } else { this.title.setText(recipient.getName(getContext())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 254db429b0e..27a351c57c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1209,7 +1209,7 @@ public void clearDirtyState(@NonNull List recipients) { } void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) { - if (!FeatureFlags.STORAGE_SERVICE) return; + if (!FeatureFlags.storageService()) return; ContentValues contentValues = new ContentValues(1); contentValues.put(DIRTY, dirtyState.getId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index dcfefe9283c..08df65289f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -69,7 +69,7 @@ public static synchronized void init(@NonNull Application application, @NonNull } public static synchronized @NonNull KeyBackupService getKeyBackupService() { - if (!FeatureFlags.KBS) throw new AssertionError(); + if (!FeatureFlags.kbs()) throw new AssertionError(); return getSignalServiceAccountManager().getKeyBackupService(IasKeyStore.getIasKeyStore(application), BuildConfig.KEY_BACKUP_ENCLAVE_NAME, BuildConfig.KEY_BACKUP_MRENCLAVE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 8b0697c8cc8..5963860e598 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -75,6 +75,7 @@ public static Map getJobFactories(@NonNull Application appl put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory()); put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory()); put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); + put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory()); put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java new file mode 100644 index 00000000000..5b3a5f07c9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class RemoteConfigRefreshJob extends BaseJob { + + public static final String KEY = "RemoteConfigRefreshJob"; + + public RemoteConfigRefreshJob() { + this(new Job.Parameters.Builder() + .setQueue("RemoteConfigRefreshJob") + .setMaxInstances(1) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); + } + + private RemoteConfigRefreshJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + Map config = ApplicationDependencies.getSignalServiceAccountManager().getRemoteConfig(); + FeatureFlags.updateDiskCache(config); + SignalStore.setRemoteConfigLastFetchTime(System.currentTimeMillis()); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RemoteConfigRefreshJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RemoteConfigRefreshJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java index e8e4c96d2bf..21d3bb3c2f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -102,7 +102,7 @@ private void handlePhoneNumberRecipient(Recipient recipient) throws IOException setProfileName(recipient, profile.getName()); setProfileAvatar(recipient, profile.getAvatar()); - if (FeatureFlags.USERNAMES) setUsername(recipient, profile.getUsername()); + if (FeatureFlags.usernames()) setUsername(recipient, profile.getUsername()); setProfileCapabilities(recipient, profile.getCapabilities()); setIdentityKey(recipient, profile.getIdentityKey()); setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java index cbc37d01efb..d89afa53bb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java @@ -67,7 +67,7 @@ private StorageForcePushJob(@NonNull Parameters parameters) { @Override protected void onRun() throws IOException, RetryLaterException { - if (!FeatureFlags.STORAGE_SERVICE) throw new AssertionError(); + if (!FeatureFlags.storageService()) throw new AssertionError(); MasterKey kbsMasterKey = SignalStore.kbsValues().getPinBackedMasterKey(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index 8b5416581da..f047ee6f7a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -79,7 +79,7 @@ private StorageSyncJob(@NonNull Parameters parameters) { @Override protected void onRun() throws IOException, RetryLaterException { - if (!FeatureFlags.STORAGE_SERVICE) throw new AssertionError(); + if (!FeatureFlags.storageService()) throw new AssertionError(); try { boolean needsMultiDeviceSync = performSync(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 9dd55ea5b46..551e944fc2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.keyvalue; +import android.content.Context; + import androidx.annotation.NonNull; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -10,12 +12,32 @@ */ public final class SignalStore { + private static final String REMOTE_CONFIG = "remote_config"; + private static final String REMOTE_CONFIG_LAST_FETCH_TIME = "remote_config_last_fetch_time"; + private SignalStore() {} public static KbsValues kbsValues() { return new KbsValues(getStore()); } + public static String getRemoteConfig() { + return getStore().getString(REMOTE_CONFIG, null); + } + + public static void setRemoteConfig(String value) { + putString(REMOTE_CONFIG, value); + } + + public static long getRemoteConfigLastFetchTime() { + return getStore().getLong(REMOTE_CONFIG_LAST_FETCH_TIME, 0); + } + + public static void setRemoteConfigLastFetchTime(long time) { + putLong(REMOTE_CONFIG_LAST_FETCH_TIME, time); + } + + /** * Ensures any pending writes are finished. Only intended to be called by * {@link SignalUncaughtExceptionHandler}. diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockDialog.java index 120610677e8..ca343dd6c76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockDialog.java @@ -126,7 +126,7 @@ private static TextWatcher getV1PinWatcher(@NonNull Context context, AlertDialog dialog.dismiss(); RegistrationLockReminders.scheduleReminder(context, true); - if (FeatureFlags.KBS) { + if (FeatureFlags.kbs()) { Log.i(TAG, "Pin V1 successfully remembered, scheduling a migration to V2"); ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob()); } @@ -201,7 +201,7 @@ protected void onPreExecute() { @Override protected Boolean doInBackground(Void... voids) { try { - if (!FeatureFlags.KBS) { + if (!FeatureFlags.kbs()) { Log.i(TAG, "Setting V1 pin"); SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); accountManager.setPin(pinValue); @@ -282,7 +282,7 @@ protected void onPreExecute() { @Override protected Boolean doInBackground(Void... voids) { try { - if (!FeatureFlags.KBS) { + if (!FeatureFlags.kbs()) { Log.i(TAG, "Removing v1 registration lock pin from server"); ApplicationDependencies.getSignalServiceAccountManager().removeV1Pin(); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitLogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitLogFragment.java index 7eaa1dd130a..c924856fba8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitLogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitLogFragment.java @@ -52,6 +52,8 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.annimon.stream.Stream; + import org.json.JSONException; import org.json.JSONObject; import org.thoughtcrime.securesms.ApplicationContext; @@ -61,6 +63,7 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logsubmit.util.Scrubber; import org.thoughtcrime.securesms.util.BucketInfo; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -111,6 +114,7 @@ public class SubmitLogFragment extends Fragment { private static final String HEADER_POWER = "========== POWER =========="; private static final String HEADER_THREADS = "===== BLOCKED THREADS ====="; private static final String HEADER_PERMISSIONS = "======= PERMISSIONS ======="; + private static final String HEADER_FLAGS = "====== FEATURE FLAGS ======"; private static final String HEADER_LOGCAT = "========== LOGCAT ========="; private static final String HEADER_LOGGER = "========== LOGGER ========="; @@ -411,6 +415,11 @@ protected String doInBackground(Void... voids) { .append(buildBlockedThreads()) .append("\n\n\n"); + stringBuilder.append(HEADER_FLAGS) + .append("\n\n") + .append(buildFlags()) + .append("\n\n\n"); + stringBuilder.append(HEADER_PERMISSIONS) .append("\n\n") .append(buildPermissions(context)) @@ -628,6 +637,28 @@ private static CharSequence buildPermissions(@NonNull Context context) { return out; } + private static CharSequence buildFlags() { + StringBuilder out = new StringBuilder(); + Map remote = FeatureFlags.getRemoteValues(); + Map forced = FeatureFlags.getForcedValues(); + int remoteLength = Stream.of(remote.keySet()).map(String::length).max(Integer::compareTo).orElse(0); + int forcedLength = Stream.of(forced.keySet()).map(String::length).max(Integer::compareTo).orElse(0); + + out.append("-- Remote\n"); + for (Map.Entry entry : remote.entrySet()) { + out.append(Util.rightPad(entry.getKey(), remoteLength)).append(": ").append(entry.getValue()).append("\n"); + } + out.append("\n"); + + out.append("-- Forced\n"); + for (Map.Entry entry : forced.entrySet()) { + out.append(Util.rightPad(entry.getKey(), forcedLength)).append(": ").append(entry.getValue()).append("\n"); + } + + return out; + } + + private static Iterable getSupportedAbis() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return Arrays.asList(Build.SUPPORTED_ABIS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java index f6d1ad59402..63ce4a90790 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java @@ -55,7 +55,7 @@ private RegistrationPinV2MigrationJob(@NonNull Parameters parameters) { @Override protected void onRun() throws IOException, UnauthenticatedResponseException, KeyBackupServicePinException { - if (!FeatureFlags.KBS) { + if (!FeatureFlags.kbs()) { Log.i(TAG, "Not migrating pin to KBS"); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index 3e7b5d7e37f..a2d048aad29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -203,7 +203,7 @@ private void initializeResources(@NonNull View view) { this.usernameLabel = view.findViewById(R.id.profile_overview_username_label); this.nextIntent = getArguments().getParcelable(NEXT_INTENT); - if (FeatureFlags.USERNAMES && getArguments().getBoolean(DISPLAY_USERNAME, false)) { + if (FeatureFlags.usernames() && getArguments().getBoolean(DISPLAY_USERNAME, false)) { username.setVisibility(View.VISIBLE); usernameEditButton.setVisibility(View.VISIBLE); usernameLabel.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index fa7fda32bbc..900cf31f21b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -167,7 +167,7 @@ public class Recipient { } else if (!recipient.isRegistered()) { db.markRegistered(recipient.getId()); - if (FeatureFlags.UUIDS) { + if (FeatureFlags.uuids()) { Log.i(TAG, "No UUID! Scheduling a fetch."); ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(recipient, false)); } @@ -175,7 +175,7 @@ public class Recipient { return resolved(recipient.getId()); } else if (uuid != null) { - if (FeatureFlags.UUIDS || e164 != null) { + if (FeatureFlags.uuids() || e164 != null) { RecipientId id = db.getOrInsertFromUuid(uuid); db.markRegistered(id, uuid); @@ -193,7 +193,7 @@ public class Recipient { if (!recipient.isRegistered()) { db.markRegistered(recipient.getId()); - if (FeatureFlags.UUIDS) { + if (FeatureFlags.uuids()) { Log.i(TAG, "No UUID! Scheduling a fetch."); ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(recipient, false)); } @@ -247,7 +247,7 @@ public class Recipient { if (UuidUtil.isUuid(identifier)) { UUID uuid = UuidUtil.parseOrThrow(identifier); - if (FeatureFlags.UUIDS) { + if (FeatureFlags.uuids()) { id = db.getOrInsertFromUuid(uuid); } else { Optional possibleId = db.getByUuid(uuid); @@ -383,8 +383,8 @@ public boolean isLocalNumber() { */ @Deprecated public @NonNull String toShortString(@NonNull Context context) { - if (FeatureFlags.PROFILE_DISPLAY) return getDisplayName(context); - else return Optional.fromNullable(getName(context)).or(getSmsAddress()).or(""); + if (FeatureFlags.profileDisplay()) return getDisplayName(context); + else return Optional.fromNullable(getName(context)).or(getSmsAddress()).or(""); } public @NonNull String getDisplayName(@NonNull Context context) { @@ -408,7 +408,7 @@ public boolean isLocalNumber() { } public @NonNull Optional getUsername() { - if (FeatureFlags.USERNAMES) { + if (FeatureFlags.usernames()) { return Optional.fromNullable(username); } else { return Optional.absent(); @@ -529,7 +529,7 @@ public Optional getDefaultSubscriptionId() { } public @Nullable String getCustomLabel() { - if (FeatureFlags.PROFILE_DISPLAY) throw new AssertionError("This method should never be called if PROFILE_DISPLAY is enabled."); + if (FeatureFlags.profileDisplay()) throw new AssertionError("This method should never be called if PROFILE_DISPLAY is enabled."); return customLabel; } @@ -655,10 +655,10 @@ public boolean isForceSmsSelection() { * @return True if this recipient can support receiving UUID-only messages, otherwise false. */ public boolean isUuidSupported() { - if (FeatureFlags.USERNAMES) { + if (FeatureFlags.usernames()) { return true; } else { - return FeatureFlags.UUIDS && uuidSupported; + return FeatureFlags.uuids() && uuidSupported; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index ad74d4c8d48..b2897aae256 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -44,7 +44,7 @@ public class RecipientUtil { throw new AssertionError(recipient.getId() + " - No UUID or phone number!"); } - if (FeatureFlags.UUIDS && !recipient.getUuid().isPresent()) { + if (FeatureFlags.uuids() && !recipient.getUuid().isPresent()) { Log.i(TAG, recipient.getId() + " is missing a UUID..."); try { RegisteredState state = DirectoryHelper.refreshDirectoryFor(context, recipient, false); @@ -110,7 +110,7 @@ public static void unblock(@NonNull Context context, @NonNull Recipient recipien @WorkerThread public static boolean isRecipientMessageRequestAccepted(@NonNull Context context, @Nullable Recipient recipient) { - if (recipient == null || !FeatureFlags.MESSAGE_REQUESTS) return true; + if (recipient == null || !FeatureFlags.messageRequests()) return true; Recipient resolved = recipient.resolve(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java index 16fca0bccd4..66ab07b6733 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java @@ -55,7 +55,7 @@ public final class CodeVerificationRequest { static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException { if (basicStorageCredentials == null) return null; - if (!FeatureFlags.KBS) return null; + if (!FeatureFlags.kbs()) return null; return ApplicationDependencies.getKeyBackupService().getToken(basicStorageCredentials); } @@ -214,7 +214,7 @@ private static void verifyAccount(@NonNull Context context, //noinspection deprecation Only acceptable place to write the old pin enabled state. TextSecurePreferences.setV1RegistrationLockEnabled(context, pin != null); if (pin != null) { - if (FeatureFlags.KBS) { + if (FeatureFlags.kbs()) { Log.i(TAG, "Pin V1 successfully entered during registration, scheduling a migration to Pin V2"); ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob()); } @@ -230,7 +230,7 @@ private static void verifyAccount(@NonNull Context context, } private static void repostPinToResetTries(@NonNull Context context, @Nullable String pin, @NonNull RegistrationLockData kbsData) { - if (!FeatureFlags.KBS) return; + if (!FeatureFlags.kbs()) return; if (pin == null) return; @@ -264,7 +264,7 @@ private static void repostPinToResetTries(@NonNull Context context, @Nullable St return null; } - if (!FeatureFlags.KBS) { + if (!FeatureFlags.kbs()) { Log.w(TAG, "User appears to have a KBS pin, but this build has KBS off."); return null; } 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 fc5ea68050e..d99e9d9c3b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -1,28 +1,192 @@ package org.thoughtcrime.securesms.util; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + /** - * A location for constants that allows us to turn features on and off during development. - * After a feature has been launched, the flag should be removed. + * A location for flags that can be set locally and remotely. These flags can guard features that + * are not yet ready to be activated. + * + * When creating a new flag: + * - Create a new string constant using {@link #generateKey(String)}) + * - Add a method to retrieve the value using {@link #getValue(String, boolean)}. You can also add + * other checks here, like requiring other flags. + * - If you would like to force a value for testing, place an entry in {@link #FORCED_VALUES}. When + * launching a feature that is planned to be updated via a remote config, do not forget to + * remove the entry! */ -public class FeatureFlags { +public final class FeatureFlags { + + private static final String TAG = Log.tag(FeatureFlags.class); + + private static final String PREFIX = "android."; + private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); + + private static final String UUIDS = generateKey("uuids"); + private static final String PROFILE_DISPLAY = generateKey("profileDisplay"); + private static final String MESSAGE_REQUESTS = generateKey("messageRequests"); + private static final String USERNAMES = generateKey("usernames"); + private static final String KBS = generateKey("kbs"); + private static final String STORAGE_SERVICE = generateKey("storageService"); + private static final String REACTION_SENDING = generateKey("reactionSending"); + + /** + * Values in this map will take precedence over any value. If you do not wish to have any sort of + * override, simply don't put a value in this map. You should never commit additions to this map + * for flags that you plan on updating remotely. + */ + private static final Map FORCED_VALUES = new HashMap() {{ + put(UUIDS, false); + put(PROFILE_DISPLAY, false); + put(MESSAGE_REQUESTS, false); + put(USERNAMES, false); + put(KBS, false); + put(STORAGE_SERVICE, false); + put(REACTION_SENDING, false); + }}; + + private static final Map REMOTE_VALUES = new HashMap<>(); + + private FeatureFlags() {} + + public static void init() { + scheduleFetchIfNecessary(); + REMOTE_VALUES.putAll(parseStoredConfig()); + } + + public static void updateDiskCache(@NonNull Map config) { + try { + JSONObject filtered = new JSONObject(); + + for (Map.Entry entry : config.entrySet()) { + if (entry.getKey().startsWith(PREFIX)) { + filtered.put(entry.getKey(), (boolean) entry.getValue()); + } + } + + SignalStore.setRemoteConfig(filtered.toString()); + } catch (JSONException e) { + throw new AssertionError(e); + } + } + /** UUID-related stuff that shouldn't be activated until the user-facing launch. */ - public static final boolean UUIDS = false; + public static boolean uuids() { + return getValue(UUIDS, false); + } /** Favoring profile names when displaying contacts. */ - public static final boolean PROFILE_DISPLAY = UUIDS; + public static boolean profileDisplay() { + return getValue(PROFILE_DISPLAY, false); + } /** MessageRequest stuff */ - public static final boolean MESSAGE_REQUESTS = UUIDS; + public static boolean messageRequests() { + return getValue(MESSAGE_REQUESTS, false); + } - /** Creating usernames, sending messages by username. Requires {@link #UUIDS}. */ - public static final boolean USERNAMES = false; + /** Creating usernames, sending messages by username. Requires {@link #uuids()}. */ + public static boolean usernames() { + boolean value = getValue(USERNAMES, false); + if (value && !uuids()) throw new MissingFlagRequirementError(); + return value; + } /** Set or migrate PIN to KBS */ - public static final boolean KBS = false; + public static boolean kbs() { + return getValue(KBS, false); + } - /** Storage service. Requires {@link #KBS}. */ - public static final boolean STORAGE_SERVICE = false; + /** Storage service. Requires {@link #kbs()}. */ + public static boolean storageService() { + boolean value = getValue(STORAGE_SERVICE, false); + if (value && !kbs()) throw new MissingFlagRequirementError(); + return value; + } /** Send support for reactions. */ - public static final boolean REACTION_SENDING = false; + public static boolean reactionSending() { + return getValue(REACTION_SENDING, false); + } + + /** Only for rendering debug info. */ + public static @NonNull Map getRemoteValues() { + return new TreeMap<>(REMOTE_VALUES); + } + + /** Only for rendering debug info. */ + public static @NonNull Map getForcedValues() { + return new TreeMap<>(FORCED_VALUES); + } + + private static @NonNull String generateKey(@NonNull String key) { + return PREFIX + key; + } + + private static boolean getValue(@NonNull String key, boolean defaultValue) { + Boolean forced = FORCED_VALUES.get(key); + if (forced != null) { + return forced; + } + + Boolean remote = REMOTE_VALUES.get(key); + if (remote != null) { + return remote; + } + + return defaultValue; + } + + private static void scheduleFetchIfNecessary() { + long timeSinceLastFetch = System.currentTimeMillis() - SignalStore.getRemoteConfigLastFetchTime(); + + if (timeSinceLastFetch > FETCH_INTERVAL) { + Log.i(TAG, "Scheduling remote config refresh."); + ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); + } else { + Log.i(TAG, "Skipping remote config refresh. Refreshed " + timeSinceLastFetch + " ms ago."); + } + } + + private static Map parseStoredConfig() { + Map parsed = new HashMap<>(); + String stored = SignalStore.getRemoteConfig(); + + if (TextUtils.isEmpty(stored)) { + Log.i(TAG, "No remote config stored. Skipping."); + return parsed; + } + + try { + JSONObject root = new JSONObject(stored); + Iterator iter = root.keys(); + + while (iter.hasNext()) { + String key = iter.next(); + parsed.put(key, root.getBoolean(key)); + } + } catch (JSONException e) { + SignalStore.setRemoteConfig(null); + throw new AssertionError("Failed to parse! Cleared storage."); + } + + return parsed; + } + + private static final class MissingFlagRequirementError extends Error { + } } 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 59bbedc71af..27603ce2ad3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -122,6 +122,19 @@ public static String join(List list, String delimeter) { return sb.toString(); } + public static String rightPad(String value, int length) { + if (value.length() >= length) { + return value; + } + + StringBuilder out = new StringBuilder(value); + while (out.length() < length) { + out.append(" "); + } + + return out.toString(); + } + public static ExecutorService newSingleThreadedLifoExecutor() { ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingLifoQueue()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index b99014bc127..7adc13ddf04 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -43,6 +43,7 @@ import org.whispersystems.signalservice.internal.push.ProfileAvatarData; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil; +import org.whispersystems.signalservice.internal.push.RemoteConfigResponse; import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import org.whispersystems.signalservice.internal.storage.protos.ReadOperation; @@ -501,6 +502,16 @@ public Optional writeStorageRecords(byte[] storageService } } + public Map getRemoteConfig() throws IOException { + RemoteConfigResponse response = this.pushServiceSocket.getRemoteConfig(); + Map out = new HashMap<>(); + + for (RemoteConfigResponse.Config config : response.getConfig()) { + out.put(config.getName(), config.isEnabled()); + } + + return out; + } public String getNewDeviceVerificationCode() throws IOException { 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 40182eacdab..806d805fc35 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 @@ -731,6 +731,11 @@ public Optional writeStorageContacts(String authToken, WriteOpe } } + public RemoteConfigResponse getRemoteConfig() throws IOException { + String response = makeServiceRequest("/v1/config", "GET", null); + return JsonUtil.fromJson(response, RemoteConfigResponse.class); + } + public void setSoTimeoutMillis(long soTimeoutMillis) { this.soTimeoutMillis = soTimeoutMillis; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteConfigResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteConfigResponse.java new file mode 100644 index 00000000000..a7a89d154fa --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RemoteConfigResponse.java @@ -0,0 +1,30 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class RemoteConfigResponse { + @JsonProperty + private List config; + + public List getConfig() { + return config; + } + + public static class Config { + @JsonProperty + private String name; + + @JsonProperty + private boolean enabled; + + public String getName() { + return name; + } + + public boolean isEnabled() { + return enabled; + } + } +}