Skip to content

Commit

Permalink
Update Settings design: Add feature flag & new Settings screen (#5300)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/488551667048375/1208785611228225/f

### Description

Adds a feature flag and new screen for the new updated Settings screen
designs.

For now it's the same layout as before, this is just laying the
groundwork for the rest of the implementation.

So you can see that the flag is working, the new Settings screen has a
temporary title of "New Settings" which will be removed when the UI
changes enough that it's perceivable.

I also added the ability to trigger notifications from dev settings so
it's easier to test the ClearData notification route to opening
settings.

### Steps to test this PR

- [x] Turn on the feature flag "newSettings" 

_Opening Settings from the Browser menu_
- [x] Click overflow menu
- [x] Press Settings button
- [x] Ensure "New Settings" is displayed at the top

_Opening Settings from ADS shortcut_
- [x] Add the ADS shortcut via phone homescreen
- [x] Press Shortcut 
- [x] ADS screen should open
- [x] Press back
- [x] Ensure "New Settings" is displayed at the top

_Clear Data Notification_
- [x] Open new Notifications screen in Dev Settings
- [x] Click "Data clearing set to manual" 
- [x] Click on the notification
- [x] Ensure "New Settings" is displayed at the top

_VPN Disabled Notification_
- [x] Open new Notifications screen in Dev Settings
- [x] Click "NetP Disabled" 
- [x] Click on the notification
- [x] Ensure "New Settings" is displayed at the top

_Clear Data Notification_
- [x] Open new Notifications screen in Dev Settings
- [x] Click "Data clearing set to manual" 
- [x] Click on the notification
- [x] Ensure "New Settings" is displayed at the top

_New Tab Page_
- [x] Ensure "pluginNewTabPage" feature flag is enabled
- [x] Open a new tab
- [x] Click the "Settings" shortcut
- [x] Ensure "New Settings" is displayed at the top

_Tab Switcher_
- [x] Open Tab Switcher screen
- [x] Click overflow menu
- [x] Click "Settings"
- [x] Ensure "New Settings" is displayed at the top

_Privacy Pro_
- [x] Simplest way to see it is to edit ln 126 in `SpecialUrlDetector`
to true and load a web page
- [x] Press back
- [x] New settings should be visible

### UI changes

N/A
  • Loading branch information
mikescamell authored Nov 27, 2024
1 parent 99d5534 commit fdd1cfa
Show file tree
Hide file tree
Showing 19 changed files with 1,667 additions and 408 deletions.
4 changes: 4 additions & 0 deletions app/src/internal/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
android:name="com.duckduckgo.app.dev.settings.DevSettingsActivity"
android:label="@string/devSettingsTitle"
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" />
<activity
android:name="com.duckduckgo.app.dev.settings.notifications.NotificationsActivity"
android:label="@string/devSettingsScreenNotificationsTitle"
android:parentActivityName="com.duckduckgo.app.dev.settings.DevSettingsActivity" />
<activity
android:name="com.duckduckgo.app.audit.AuditSettingsActivity"
android:label="@string/auditSettingsTitle"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ import com.duckduckgo.app.browser.R.layout
import com.duckduckgo.app.browser.databinding.ActivityDevSettingsBinding
import com.duckduckgo.app.browser.webview.WebContentDebuggingFeature
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.ChangePrivacyConfigUrl
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.CustomTabs
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.Notifications
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.OpenUASelector
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.SendTdsIntent
import com.duckduckgo.app.dev.settings.DevSettingsViewModel.Command.ShowSavedSitesClearedConfirmation
import com.duckduckgo.app.dev.settings.customtabs.CustomTabsInternalSettingsActivity
import com.duckduckgo.app.dev.settings.db.UAOverride
import com.duckduckgo.app.dev.settings.notifications.NotificationsActivity
import com.duckduckgo.app.dev.settings.privacy.TrackerDataDevReceiver.Companion.DOWNLOAD_TDS_INTENT_ACTION
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.menu.PopupMenu
Expand Down Expand Up @@ -98,6 +105,7 @@ class DevSettingsActivity : DuckDuckGoActivity() {
binding.overrideUserAgentSelector.setOnClickListener { viewModel.onUserAgentSelectorClicked() }
binding.overridePrivacyRemoteConfigUrl.setOnClickListener { viewModel.onRemotePrivacyUrlClicked() }
binding.customTabs.setOnClickListener { viewModel.customTabsClicked() }
binding.notifications.setOnClickListener { viewModel.notificationsClicked() }
}

private fun observeViewModel() {
Expand All @@ -119,14 +127,14 @@ class DevSettingsActivity : DuckDuckGoActivity() {
.launchIn(lifecycleScope)
}

private fun processCommand(it: Command?) {
private fun processCommand(it: Command) {
when (it) {
is Command.SendTdsIntent -> sendTdsIntent()
is Command.OpenUASelector -> showUASelector()
is Command.ShowSavedSitesClearedConfirmation -> showSavedSitesClearedConfirmation()
is Command.ChangePrivacyConfigUrl -> showChangePrivacyUrl()
is Command.CustomTabs -> showCustomTabs()
else -> TODO()
is SendTdsIntent -> sendTdsIntent()
is OpenUASelector -> showUASelector()
is ShowSavedSitesClearedConfirmation -> showSavedSitesClearedConfirmation()
is ChangePrivacyConfigUrl -> showChangePrivacyUrl()
is CustomTabs -> showCustomTabs()
Notifications -> showNotifications()
}
}

Expand Down Expand Up @@ -167,6 +175,10 @@ class DevSettingsActivity : DuckDuckGoActivity() {
startActivity(CustomTabsInternalSettingsActivity.intent(this), options)
}

private fun showNotifications() {
startActivity(NotificationsActivity.intent(this))
}

companion object {
fun intent(context: Context): Intent {
return Intent(context, DevSettingsActivity::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class DevSettingsViewModel @Inject constructor(
object ShowSavedSitesClearedConfirmation : Command()
object ChangePrivacyConfigUrl : Command()
object CustomTabs : Command()
data object Notifications : Command()
}

private val viewState = MutableStateFlow(ViewState())
Expand Down Expand Up @@ -137,4 +138,8 @@ class DevSettingsViewModel @Inject constructor(
command.send(Command.ShowSavedSitesClearedConfirmation)
}
}

fun notificationsClicked() {
viewModelScope.launch { command.send(Command.Notifications) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.dev.settings.notifications

import android.app.Notification
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.databinding.ActivityNotificationsBinding
import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.Command.TriggerNotification
import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.ViewState
import com.duckduckgo.app.notification.NotificationFactory
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.view.listitem.TwoLineListItem
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.notification.checkPermissionAndNotify
import com.duckduckgo.di.scopes.ActivityScope
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

@InjectWith(ActivityScope::class)
class NotificationsActivity : DuckDuckGoActivity() {

@Inject
lateinit var viewModel: NotificationViewModel

@Inject
lateinit var factory: NotificationFactory

private val binding: ActivityNotificationsBinding by viewBinding()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupToolbar(binding.includeToolbar.toolbar)

observeViewState()
observeCommands()
}

private fun observeViewState() {
viewModel.viewState.flowWithLifecycle(lifecycle, STARTED).onEach { render(it) }
.launchIn(lifecycleScope)
}

private fun observeCommands() {
viewModel.command.flowWithLifecycle(lifecycle, STARTED).onEach { command ->
when (command) {
is TriggerNotification -> addNotification(id = command.notificationItem.id, notification = command.notificationItem.notification)
}
}.launchIn(lifecycleScope)
}

private fun render(viewState: ViewState) {
viewState.scheduledNotifications.forEach { notificationItem ->
buildNotificationItem(
title = notificationItem.title,
subtitle = notificationItem.subtitle,
onClick = { viewModel.onNotificationItemClick(notificationItem) },
).also {
binding.scheduledNotificationsContainer.addView(it)
}
}

viewState.vpnNotifications.forEach { notificationItem ->
buildNotificationItem(
title = notificationItem.title,
subtitle = notificationItem.subtitle,
onClick = { viewModel.onNotificationItemClick(notificationItem) },
).also {
binding.vpnNotificationsContainer.addView(it)
}
}
}

private fun buildNotificationItem(
title: String,
subtitle: String,
onClick: () -> Unit,
): TwoLineListItem {
return TwoLineListItem(this).apply {
setPrimaryText(title)
setSecondaryText(subtitle)
setOnClickListener { onClick() }
}
}

private fun addNotification(
id: Int,
notification: Notification,
) {
NotificationManagerCompat.from(this)
.checkPermissionAndNotify(context = this, id = id, notification = notification)
}

companion object {

fun intent(context: Context): Intent {
return Intent(context, NotificationsActivity::class.java)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.dev.settings.notifications

import android.app.Notification
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.ViewState.NotificationItem
import com.duckduckgo.app.notification.NotificationFactory
import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin
import com.duckduckgo.app.survey.api.SurveyRepository
import com.duckduckgo.app.survey.model.Survey
import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED
import com.duckduckgo.app.survey.notification.SurveyAvailableNotification
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.networkprotection.impl.notification.NetPDisabledNotificationBuilder
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

@ContributesViewModel(ActivityScope::class)
class NotificationViewModel @Inject constructor(
private val applicationContext: Context,
private val dispatcher: DispatcherProvider,
private val schedulableNotificationPluginPoint: PluginPoint<SchedulableNotificationPlugin>,
private val factory: NotificationFactory,
private val surveyRepository: SurveyRepository,
private val netPDisabledNotificationBuilder: NetPDisabledNotificationBuilder,
) : ViewModel() {

data class ViewState(
val scheduledNotifications: List<NotificationItem> = emptyList(),
val vpnNotifications: List<NotificationItem> = emptyList(),
) {

data class NotificationItem(
val id: Int,
val title: String,
val subtitle: String,
val notification: Notification,
)
}

sealed class Command {
data class TriggerNotification(val notificationItem: NotificationItem) : Command()
}

private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()

private val _command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
val command = _command.receiveAsFlow()

init {
viewModelScope.launch {
val scheduledNotificationItems = schedulableNotificationPluginPoint.getPlugins().map { plugin ->

// The survey notification will crash if we do not have a survey in the database
if (plugin.getSchedulableNotification().javaClass == SurveyAvailableNotification::class.java) {
withContext(dispatcher.io()) {
addTestSurvey()
}
}

// the survey intent hits the DB, so we need to do this on IO
val launchIntent = withContext(dispatcher.io()) { plugin.getLaunchIntent() }

NotificationItem(
id = plugin.getSpecification().systemId,
title = plugin.getSpecification().title,
subtitle = plugin.getSpecification().description,
notification = factory.createNotification(plugin.getSpecification(), launchIntent, null),
)
}

val netPDisabledNotificationItem = NotificationItem(
id = 0,
title = "NetP Disabled",
subtitle = "NetP is disabled",
notification = netPDisabledNotificationBuilder.buildVpnAccessRevokedNotification(applicationContext),
)

_viewState.update {
it.copy(
scheduledNotifications = scheduledNotificationItems,
vpnNotifications = listOf(netPDisabledNotificationItem),
)
}
}
}

private fun addTestSurvey() {
surveyRepository.persistSurvey(
Survey(
"testSurveyId",
"https://youtu.be/dQw4w9WgXcQ?si=iztopgFbXoWUnoOE",
daysInstalled = 1,
status = SCHEDULED,
),
)
}

fun onNotificationItemClick(notificationItem: NotificationItem) {
viewModelScope.launch {
_command.send(Command.TriggerNotification(notificationItem))
}
}
}
7 changes: 7 additions & 0 deletions app/src/internal/res/layout/activity_dev_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
app:secondaryText="@string/devSettingsScreenCustomTabsSubtitle"
/>

<com.duckduckgo.common.ui.view.listitem.TwoLineListItem
android:id="@+id/notifications"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryText="@string/devSettingsScreenNotificationsTitle"
app:secondaryText="@string/devSettingsScreenNotificationsSubtitle" />

<com.duckduckgo.common.ui.view.listitem.SectionHeaderListItem
android:id="@+id/privacyTitle"
android:layout_width="wrap_content"
Expand Down
Loading

0 comments on commit fdd1cfa

Please sign in to comment.