Skip to content

Commit

Permalink
add notifications screen
Browse files Browse the repository at this point in the history
You can see in the viewmodel that I had some issues with the SurveyAvailableNotification

1. The SurveyAvailableNotification needs a notification in the database, otherwise we get a NoElement exception as we call "last()" on the list of surveys when getting them in the intent
2. The intent hits a repo that hits a DAO, that is not a suspend function, so we have to be off the main thread
  • Loading branch information
mikescamell committed Nov 20, 2024
1 parent 3ef03a7 commit ad25ebd
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 0 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
@@ -0,0 +1,103 @@
/*
* 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 kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

@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.notificationItems.forEach { notificationItem ->
TwoLineListItem(this).apply {

setPrimaryText(notificationItem.title)
setSecondaryText(notificationItem.subtitle)
setOnClickListener {
viewModel.onNotificationItemClick(notificationItem)
}
}.also {
binding.notificationsContainer.addView(it)
}
}
}

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,115 @@
/*
* 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 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 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 dispatcher: DispatcherProvider,
private val schedulableNotificationPluginPoint: PluginPoint<SchedulableNotificationPlugin>,
private val factory: NotificationFactory,
private val surveyRepository: SurveyRepository,
) : ViewModel() {

data class ViewState(
val notificationItems: 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 notificationItems = 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),
)
}

_viewState.update { it.copy(notificationItems = notificationItems) }
}
}

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))
}
}
}
41 changes: 41 additions & 0 deletions app/src/internal/res/layout/activity_notifications.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2021 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.
-->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.duckduckgo.app.dev.settings.notifications.NotificationsActivity">

<include
android:id="@+id/includeToolbar"
layout="@layout/include_default_toolbar" />

<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">

<LinearLayout
android:id="@+id/notificationsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />

</ScrollView>
</LinearLayout>

0 comments on commit ad25ebd

Please sign in to comment.