Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Settings design: Add feature flag & new Settings screen #5300

Merged
merged 14 commits into from
Nov 27, 2024
Merged
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
Loading