package org.thoughtcrime.securesms.components.settings.app.internal import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.view.View import android.widget.Toast import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.signal.core.util.AppUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SimpleTask import org.signal.ringrtc.CallManager import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.database.LocalMetricsDatabase import org.thoughtcrime.securesms.database.LogDatabase import org.thoughtcrime.securesms.database.MegaphoneDatabase import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob import org.thoughtcrime.securesms.jobs.StorageForcePushJob import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.megaphone.MegaphoneRepository import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.payments.DataExportUtil import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.ConversationUtil import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate import java.util.Optional import java.util.concurrent.TimeUnit import kotlin.math.max import kotlin.time.Duration.Companion.seconds class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) { private lateinit var viewModel: InternalSettingsViewModel private var scrollToPosition: Int = 0 private val layoutManager: LinearLayoutManager? get() = recyclerView?.layoutManager as? LinearLayoutManager override fun onPause() { super.onPause() val firstVisiblePosition: Int? = layoutManager?.findFirstVisibleItemPosition() if (firstVisiblePosition != null) { SignalStore.internalValues().lastScrollPosition = firstVisiblePosition } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) scrollToPosition = SignalStore.internalValues().lastScrollPosition } override fun bindAdapter(adapter: MappingAdapter) { val repository = InternalSettingsRepository(requireContext()) val factory = InternalSettingsViewModel.Factory(repository) viewModel = ViewModelProvider(this, factory)[InternalSettingsViewModel::class.java] viewModel.state.observe(viewLifecycleOwner) { adapter.submitList(getConfiguration(it).toMappingModelList()) { if (scrollToPosition != 0) { layoutManager?.scrollToPositionWithOffset(scrollToPosition, 0) scrollToPosition = 0 } } } } private fun getConfiguration(state: InternalSettingsState): DSLConfiguration { return configure { sectionHeaderPref(DSLSettingsText.from("Account")) clickPref( title = DSLSettingsText.from("Refresh attributes"), summary = DSLSettingsText.from("Forces a write of capabilities on to the server followed by a read."), onClick = { refreshAttributes() } ) clickPref( title = DSLSettingsText.from("Refresh profile"), summary = DSLSettingsText.from("Forces a refresh of your own profile."), onClick = { refreshProfile() } ) clickPref( title = DSLSettingsText.from("Rotate profile key"), summary = DSLSettingsText.from("Creates a new versioned profile, and triggers an update of any GV2 group you belong to."), onClick = { rotateProfileKey() } ) clickPref( title = DSLSettingsText.from("Refresh remote config"), summary = DSLSettingsText.from("Forces a refresh of the remote config locally instead of waiting for the elapsed time."), onClick = { refreshRemoteValues() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Miscellaneous")) switchPref( title = DSLSettingsText.from("'Internal Details' button"), summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."), isChecked = state.seeMoreUserDetails, onClick = { viewModel.setSeeMoreUserDetails(!state.seeMoreUserDetails) } ) switchPref( title = DSLSettingsText.from("Shake to Report"), summary = DSLSettingsText.from("Shake your phone to easily submit and share a debug log."), isChecked = state.shakeToReport, onClick = { viewModel.setShakeToReport(!state.shakeToReport) } ) clickPref( title = DSLSettingsText.from("Clear keep longer logs"), onClick = { clearKeepLongerLogs() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Payments")) clickPref( title = DSLSettingsText.from("Copy payments data"), summary = DSLSettingsText.from("Copy all payment records to clipboard."), onClick = { copyPaymentsDataToClipboard() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Storage Service")) switchPref( title = DSLSettingsText.from("Disable syncing"), summary = DSLSettingsText.from("Prevent syncing any data to/from storage service."), isChecked = state.disableStorageService, onClick = { viewModel.setDisableStorageService(!state.disableStorageService) } ) clickPref( title = DSLSettingsText.from("Sync now"), summary = DSLSettingsText.from("Enqueue a normal storage service sync."), onClick = { enqueueStorageServiceSync() } ) clickPref( title = DSLSettingsText.from("Overwrite remote data"), summary = DSLSettingsText.from("Forces remote storage to match the local device state."), onClick = { enqueueStorageServiceForcePush() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Groups V2")) switchPref( title = DSLSettingsText.from("Force invites"), summary = DSLSettingsText.from("Members will not be added directly to a GV2 even if they could be."), isChecked = state.gv2forceInvites, onClick = { viewModel.setGv2ForceInvites(!state.gv2forceInvites) } ) switchPref( title = DSLSettingsText.from("Ignore server changes"), summary = DSLSettingsText.from("Changes in server's response will be ignored, causing passive voice update messages if P2P is also ignored."), isChecked = state.gv2ignoreServerChanges, onClick = { viewModel.setGv2IgnoreServerChanges(!state.gv2ignoreServerChanges) } ) switchPref( title = DSLSettingsText.from("Ignore P2P changes"), summary = DSLSettingsText.from("Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice."), isChecked = state.gv2ignoreP2PChanges, onClick = { viewModel.setGv2IgnoreP2PChanges(!state.gv2ignoreP2PChanges) } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Network")) switchPref( title = DSLSettingsText.from("Force websocket mode"), summary = DSLSettingsText.from("Pretend you have no Play Services. Ignores websocket messages and keeps the websocket open in a foreground service. You have to manually force-stop the app for changes to take effect."), isChecked = state.forceWebsocketMode, onClick = { viewModel.setForceWebsocketMode(!state.forceWebsocketMode) SimpleTask.run({ val jobState = ApplicationDependencies.getJobManager().runSynchronously(RefreshAttributesJob(), 10.seconds.inWholeMilliseconds) return@run jobState.isPresent && jobState.get().isComplete }, { success -> if (success) { Toast.makeText(context, "Successfully refreshed attributes. Force-stop the app for changes to take effect.", Toast.LENGTH_SHORT).show() } else { Toast.makeText(context, "Failed to refresh attributes.", Toast.LENGTH_SHORT).show() } }) } ) switchPref( title = DSLSettingsText.from("Allow censorship circumvention toggle"), summary = DSLSettingsText.from("Allow changing the censorship circumvention toggle regardless of network connectivity."), isChecked = state.allowCensorshipSetting, onClick = { viewModel.setAllowCensorshipSetting(!state.allowCensorshipSetting) } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Conversations and Shortcuts")) clickPref( title = DSLSettingsText.from("Delete all dynamic shortcuts"), summary = DSLSettingsText.from("Click to delete all dynamic shortcuts"), onClick = { deleteAllDynamicShortcuts() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Emoji")) val emojiSummary = if (state.emojiVersion == null) { "Use built-in emoji set" } else { "Current version: ${state.emojiVersion.version} at density ${state.emojiVersion.density}" } switchPref( title = DSLSettingsText.from("Use built-in emoji set"), summary = DSLSettingsText.from(emojiSummary), isChecked = state.useBuiltInEmojiSet, onClick = { viewModel.setUseBuiltInEmoji(!state.useBuiltInEmojiSet) } ) clickPref( title = DSLSettingsText.from("Force emoji download"), summary = DSLSettingsText.from("Download the latest emoji set if it\\'s newer than what we have."), onClick = { ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(true)) } ) clickPref( title = DSLSettingsText.from("Force search index download"), summary = DSLSettingsText.from("Download the latest emoji search index if it\\'s newer than what we have."), onClick = { EmojiSearchIndexDownloadJob.scheduleImmediately() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Sender Key")) clickPref( title = DSLSettingsText.from("Clear all state"), summary = DSLSettingsText.from("Click to delete all sender key state"), onClick = { clearAllSenderKeyState() } ) clickPref( title = DSLSettingsText.from("Clear shared state"), summary = DSLSettingsText.from("Click to delete all sharing state"), onClick = { clearAllSenderKeySharedState() } ) switchPref( title = DSLSettingsText.from("Remove 2 person minimum"), summary = DSLSettingsText.from("Remove the requirement that you need at least 2 recipients to use sender key."), isChecked = state.removeSenderKeyMinimium, onClick = { viewModel.setRemoveSenderKeyMinimum(!state.removeSenderKeyMinimium) } ) switchPref( title = DSLSettingsText.from("Delay resends"), summary = DSLSettingsText.from("Delay resending messages in response to retry receipts by 10 seconds."), isChecked = state.delayResends, onClick = { viewModel.setDelayResends(!state.delayResends) } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Local Metrics")) clickPref( title = DSLSettingsText.from("Clear local metrics"), summary = DSLSettingsText.from("Click to clear all local metrics state."), onClick = { clearAllLocalMetricsState() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Group call server")) radioPref( title = DSLSettingsText.from("Production server"), summary = DSLSettingsText.from(BuildConfig.SIGNAL_SFU_URL), isChecked = state.callingServer == BuildConfig.SIGNAL_SFU_URL, onClick = { viewModel.setInternalGroupCallingServer(BuildConfig.SIGNAL_SFU_URL) } ) BuildConfig.SIGNAL_SFU_INTERNAL_NAMES.zip(BuildConfig.SIGNAL_SFU_INTERNAL_URLS) .forEach { (name, server) -> radioPref( title = DSLSettingsText.from("$name server"), summary = DSLSettingsText.from(server), isChecked = state.callingServer == server, onClick = { viewModel.setInternalGroupCallingServer(server) } ) } sectionHeaderPref(DSLSettingsText.from("Calling options")) radioListPref( title = DSLSettingsText.from("Audio processing method"), listItems = CallManager.AudioProcessingMethod.values().map { it.name }.toTypedArray(), selected = CallManager.AudioProcessingMethod.values().indexOf(state.callingAudioProcessingMethod), onSelected = { viewModel.setInternalCallingAudioProcessingMethod(CallManager.AudioProcessingMethod.values()[it]) } ) radioListPref( title = DSLSettingsText.from("Bandwidth mode"), listItems = CallManager.BandwidthMode.values().map { it.name }.toTypedArray(), selected = CallManager.BandwidthMode.values().indexOf(state.callingBandwidthMode), onSelected = { viewModel.setInternalCallingBandwidthMode(CallManager.BandwidthMode.values()[it]) } ) switchPref( title = DSLSettingsText.from("Disable Telecom integration"), isChecked = state.callingDisableTelecom, onClick = { viewModel.setInternalCallingDisableTelecom(!state.callingDisableTelecom) } ) if (SignalStore.donationsValues().getSubscriber() != null) { dividerPref() sectionHeaderPref(DSLSettingsText.from("Badges")) clickPref( title = DSLSettingsText.from("Enqueue redemption."), onClick = { enqueueSubscriptionRedemption() } ) clickPref( title = DSLSettingsText.from("Enqueue keep-alive."), onClick = { enqueueSubscriptionKeepAlive() } ) clickPref( title = DSLSettingsText.from("Set error state."), onClick = { findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDonorErrorConfigurationFragment()) } ) clickPref( title = DSLSettingsText.from("Clear keep-alive timestamps"), onClick = { SignalStore.donationsValues().subscriptionEndOfPeriodRedemptionStarted = 0L SignalStore.donationsValues().subscriptionEndOfPeriodConversionStarted = 0L SignalStore.donationsValues().setLastEndOfPeriod(0L) Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show() } ) } dividerPref() sectionHeaderPref(DSLSettingsText.from("Release channel")) clickPref( title = DSLSettingsText.from("Set last version seen back 10 versions"), onClick = { SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0) } ) clickPref( title = DSLSettingsText.from("Reset donation megaphone"), onClick = { SignalDatabase.remoteMegaphones.debugRemoveAll() MegaphoneDatabase.getInstance(ApplicationDependencies.getApplication()).let { it.delete(Megaphones.Event.REMOTE_MEGAPHONE) it.markFirstVisible(Megaphones.Event.DONATE_Q2_2022, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)) } // Force repository database cache refresh MegaphoneRepository(ApplicationDependencies.getApplication()).onFirstEverAppLaunch() } ) clickPref( title = DSLSettingsText.from("Fetch release channel"), onClick = { SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0) RetrieveRemoteAnnouncementsJob.enqueue(force = true) } ) clickPref( title = DSLSettingsText.from("Add sample note"), onClick = { viewModel.addSampleReleaseNote() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("CDS")) clickPref( title = DSLSettingsText.from("Clear history"), summary = DSLSettingsText.from("Clears all CDS history, meaning the next sync will consider all numbers to be new."), onClick = { clearCdsHistory() } ) clickPref( title = DSLSettingsText.from("Clear all service IDs"), summary = DSLSettingsText.from("Clears all known service IDs (except your own) for people that have phone numbers. Do not use on your personal device!"), onClick = { clearAllServiceIds() } ) clickPref( title = DSLSettingsText.from("Clear all profile keys"), summary = DSLSettingsText.from("Clears all known profile keys (except your own). Do not use on your personal device!"), onClick = { clearAllProfileKeys() } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("Stories")) clickPref( title = DSLSettingsText.from("Clear onboarding state"), summary = DSLSettingsText.from("Clears onboarding flag and triggers download of onboarding stories."), isEnabled = state.canClearOnboardingState, onClick = { viewModel.onClearOnboardingState() } ) clickPref( title = DSLSettingsText.from("Clear choose initial my story privacy state"), isEnabled = true, onClick = { SignalStore.storyValues().userHasBeenNotifiedAboutStories = false } ) clickPref( title = DSLSettingsText.from("Clear first time navigation state"), isEnabled = true, onClick = { SignalStore.storyValues().userHasSeenFirstNavView = false } ) clickPref( title = DSLSettingsText.from("Stories dialog launcher"), onClick = { findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToStoryDialogsLauncherFragment()) } ) dividerPref() sectionHeaderPref(DSLSettingsText.from("PNP")) clickPref( title = DSLSettingsText.from("Trigger No-Op Change Number"), summary = DSLSettingsText.from("Mimics the 'Hello world' event"), isEnabled = true, onClick = { SimpleTask.run(viewLifecycleOwner.lifecycle, { ApplicationDependencies.getJobManager().runSynchronously(PnpInitializeDevicesJob(), 10.seconds.inWholeMilliseconds) }, { state -> if (state.isPresent) { Toast.makeText(context, "Job finished with result: ${state.get()}!", Toast.LENGTH_SHORT).show() viewModel.refresh() } else { Toast.makeText(context, "Job timed out after 10 seconds!", Toast.LENGTH_SHORT).show() } }) } ) clickPref( title = DSLSettingsText.from("Reset 'PNP initialized' state"), summary = DSLSettingsText.from("Current initialized state: ${state.pnpInitialized}"), isEnabled = state.pnpInitialized, onClick = { viewModel.resetPnpInitializedState() } ) clickPref( title = DSLSettingsText.from("Clear Username education ui hint"), onClick = { SignalStore.uiHints().clearHasSeenUsernameEducation() } ) if (FeatureFlags.chatFilters()) { dividerPref() sectionHeaderPref(DSLSettingsText.from("Chat Filters")) clickPref( title = DSLSettingsText.from("Reset pull to refresh tip count"), onClick = { SignalStore.uiHints().resetNeverDisplayPullToRefreshCount() } ) } } } private fun copyPaymentsDataToClipboard() { MaterialAlertDialogBuilder(requireContext()) .setMessage( """ Local payments history will be copied to the clipboard. It may therefore compromise privacy. However, no private keys will be copied. """.trimIndent() ) .setPositiveButton( "Copy" ) { _: DialogInterface?, _: Int -> val context: Context = ApplicationDependencies.getApplication() val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager SimpleTask.run( SignalExecutors.UNBOUNDED, { val tsv = DataExportUtil.createTsv() val clip = ClipData.newPlainText(context.getString(R.string.app_name), tsv) clipboard.setPrimaryClip(clip) null }, { Toast.makeText( context, "Payments have been copied", Toast.LENGTH_SHORT ).show() } ) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun refreshAttributes() { ApplicationDependencies.getJobManager() .startChain(RefreshAttributesJob()) .then(RefreshOwnProfileJob()) .enqueue() Toast.makeText(context, "Scheduled attribute refresh", Toast.LENGTH_SHORT).show() } private fun refreshProfile() { ApplicationDependencies.getJobManager().add(RefreshOwnProfileJob()) Toast.makeText(context, "Scheduled profile refresh", Toast.LENGTH_SHORT).show() } private fun rotateProfileKey() { ApplicationDependencies.getJobManager().add(RotateProfileKeyJob()) Toast.makeText(context, "Scheduled profile key rotation", Toast.LENGTH_SHORT).show() } private fun refreshRemoteValues() { Toast.makeText(context, "Running remote config refresh, app will restart after completion.", Toast.LENGTH_LONG).show() SignalExecutors.BOUNDED.execute { val result: Optional = ApplicationDependencies.getJobManager().runSynchronously(RemoteConfigRefreshJob(), TimeUnit.SECONDS.toMillis(10)) if (result.isPresent && result.get() == JobTracker.JobState.SUCCESS) { AppUtil.restart(requireContext()) } else { Toast.makeText(context, "Failed to refresh config remote config.", Toast.LENGTH_SHORT).show() } } } private fun enqueueStorageServiceSync() { StorageSyncHelper.scheduleSyncForDataChange() Toast.makeText(context, "Scheduled routine storage sync", Toast.LENGTH_SHORT).show() } private fun enqueueStorageServiceForcePush() { ApplicationDependencies.getJobManager().add(StorageForcePushJob()) Toast.makeText(context, "Scheduled storage force push", Toast.LENGTH_SHORT).show() } private fun deleteAllDynamicShortcuts() { ConversationUtil.clearAllShortcuts(requireContext()) Toast.makeText(context, "Deleted all dynamic shortcuts.", Toast.LENGTH_SHORT).show() } private fun clearAllSenderKeyState() { SignalDatabase.senderKeys.deleteAll() SignalDatabase.senderKeyShared.deleteAll() Toast.makeText(context, "Deleted all sender key state.", Toast.LENGTH_SHORT).show() } private fun clearAllSenderKeySharedState() { SignalDatabase.senderKeyShared.deleteAll() Toast.makeText(context, "Deleted all sender key shared state.", Toast.LENGTH_SHORT).show() } private fun clearAllLocalMetricsState() { LocalMetricsDatabase.getInstance(ApplicationDependencies.getApplication()).clear() Toast.makeText(context, "Cleared all local metrics state.", Toast.LENGTH_SHORT).show() } private fun enqueueSubscriptionRedemption() { SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue() } private fun enqueueSubscriptionKeepAlive() { SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis()) } private fun clearCdsHistory() { SignalDatabase.cds.clearAll() SignalStore.misc().cdsToken = null Toast.makeText(context, "Cleared all CDS history.", Toast.LENGTH_SHORT).show() } private fun clearAllServiceIds() { MaterialAlertDialogBuilder(requireContext()) .setTitle("Clear all serviceIds?") .setMessage("Are you sure? Never do this on a non-test device.") .setPositiveButton(android.R.string.ok) { _, _ -> SignalDatabase.recipients.debugClearServiceIds() Toast.makeText(context, "Cleared all service IDs.", Toast.LENGTH_SHORT).show() } .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } .show() } private fun clearAllProfileKeys() { MaterialAlertDialogBuilder(requireContext()) .setTitle("Clear all profile keys?") .setMessage("Are you sure? Never do this on a non-test device.") .setPositiveButton(android.R.string.ok) { _, _ -> SignalDatabase.recipients.debugClearProfileData() Toast.makeText(context, "Cleared all profile keys.", Toast.LENGTH_SHORT).show() } .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } .show() } private fun clearKeepLongerLogs() { SimpleTask.run({ LogDatabase.getInstance(requireActivity().application).clearKeepLonger() }) { Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show() } } }