Merge branch 'refactor/1080_move-fcm-intent-service-code-to-worker-and-add-tests' into 'develop'

Replace FcmIntentService with ProcessPushNotificationDataWorker

See merge request android/mail/proton-mail-android!259
This commit is contained in:
Stefanija Boshkovska 2020-12-03 16:10:33 +00:00
commit dd62652748
11 changed files with 795 additions and 393 deletions

View File

@ -60,13 +60,14 @@ class UserCrypto(
override fun decrypt(message: CipherText): TextDecryptionResult =
decrypt(message, getVerificationKeys(), openPgp.time)
fun decryptMessage(message: CipherText): TextDecryptionResult {
fun decryptMessage(message: String): TextDecryptionResult {
val errorMessage = "Error decrypting message, invalid passphrase"
checkNotNull(mailboxPassword) { errorMessage }
return withCurrentKeys("Error decrypting message") { key ->
val cipherText = CipherText(message)
val unarmored = Armor.unarmor(key.privateKey.string)
val decrypted = openPgp.decryptMessageBinKey(message.armored, unarmored, mailboxPassword)
val decrypted = openPgp.decryptMessageBinKey(cipherText.armored, unarmored, mailboxPassword)
TextDecryptionResult(decrypted, false, false)
}
}

View File

@ -19,6 +19,7 @@
package ch.protonmail.android.di
import android.app.NotificationManager
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
@ -33,6 +34,7 @@ import ch.protonmail.android.api.models.doh.Proxies
import ch.protonmail.android.api.models.factories.IConverterFactory
import ch.protonmail.android.api.models.messages.receive.ServerLabel
import ch.protonmail.android.api.models.room.contacts.ContactLabel
import ch.protonmail.android.api.segments.event.AlarmReceiver
import ch.protonmail.android.core.Constants
import ch.protonmail.android.core.PREF_USERNAME
import ch.protonmail.android.core.ProtonMailApplication
@ -73,6 +75,9 @@ object ApplicationModule {
fun protonMailApplication(context: Context): ProtonMailApplication =
context.app
@Provides
fun alarmReceiver() = AlarmReceiver()
@Provides
@AlternativeApiPins
fun alternativeApiPins() = listOf(
@ -145,6 +150,11 @@ object ApplicationModule {
userManager: UserManager
) = userManager.mailSettings
@Provides
@Singleton
fun notificationManager(context: Context): NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Provides
@Singleton
fun protonRetrofitBuilder(

View File

@ -1,327 +0,0 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.fcm;
import android.app.IntentService;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.google.gson.Gson;
import java.util.Calendar;
import java.util.List;
import javax.inject.Inject;
import ch.protonmail.android.BuildConfig;
import ch.protonmail.android.activities.messageDetails.repository.MessageDetailsRepository;
import ch.protonmail.android.api.ProtonMailApiManager;
import ch.protonmail.android.api.local.SnoozeSettings;
import ch.protonmail.android.api.models.DatabaseProvider;
import ch.protonmail.android.api.models.User;
import ch.protonmail.android.api.models.messages.receive.MessageResponse;
import ch.protonmail.android.api.models.messages.receive.MessagesResponse;
import ch.protonmail.android.api.models.room.messages.Message;
import ch.protonmail.android.api.models.room.notifications.Notification;
import ch.protonmail.android.api.models.room.notifications.NotificationsDatabase;
import ch.protonmail.android.api.segments.event.AlarmReceiver;
import ch.protonmail.android.core.QueueNetworkUtil;
import ch.protonmail.android.core.UserManager;
import ch.protonmail.android.crypto.CipherText;
import ch.protonmail.android.crypto.Crypto;
import ch.protonmail.android.crypto.UserCrypto;
import ch.protonmail.android.fcm.models.NotificationData;
import ch.protonmail.android.fcm.models.NotificationEncryptedData;
import ch.protonmail.android.fcm.models.NotificationSender;
import ch.protonmail.android.servers.notification.INotificationServer;
import ch.protonmail.android.servers.notification.NotificationServer;
import ch.protonmail.android.utils.AppUtil;
import ch.protonmail.android.utils.Logger;
import ch.protonmail.android.utils.crypto.TextDecryptionResult;
import dagger.hilt.android.AndroidEntryPoint;
import io.sentry.event.EventBuilder;
import timber.log.Timber;
@AndroidEntryPoint
public class FcmIntentService extends IntentService {
private static final String TAG_FCM_INTENT_SERVICE = "FcmIntentService";
public static final String EXTRA_READ = "CMD_READ";
private static final String EXTRA_ENCRYPTED_DATA = "encryptedMessage";
private static final String EXTRA_UID = "UID";
@Inject
ProtonMailApiManager mApi;
@Inject
UserManager mUserManager;
@Inject
QueueNetworkUtil mNetworkUtils;
@Inject
MessageDetailsRepository messageDetailsRepository;
@Inject
DatabaseProvider databaseProvider;
private NotificationsDatabase notificationsDatabase;
private INotificationServer notificationServer;
public FcmIntentService() {
super("FCM");
setIntentRedelivery(true);
}
@Override
public void onCreate() {
super.onCreate();
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
notificationServer = new NotificationServer(this, notificationManager);
}
private void startMeForeground() {
final int messageId = (int) System.currentTimeMillis();
final android.app.Notification notification = notificationServer.createCheckingMailboxNotification();
startForeground(messageId, notification);
}
@Override
protected void onHandleIntent(Intent intent) {
startMeForeground();
final Bundle extras = intent.getExtras();
if (extras != null && !extras.isEmpty()) {
if (!extras.containsKey("CMD")) {
// we are always registering for push in MailboxActivity
boolean isAppInBackground = AppUtil.isAppInBackground();
if (!isAppInBackground) {
AlarmReceiver alarmReceiver = new AlarmReceiver();
alarmReceiver.setAlarm(this, true);
}
mNetworkUtils.setCurrentlyHasConnectivity(true);
NotificationData notificationData = null;
NotificationEncryptedData messageData = null;
String sessionId = "";
if (extras.containsKey(EXTRA_UID)) {
sessionId = extras.getString(EXTRA_UID, "");
}
String notificationUsername = mUserManager.getUsernameBySessionId(sessionId);
if (TextUtils.isEmpty(notificationUsername)) {
// we do not show notifications for unknown/inactive users
return;
}
User user = mUserManager.getUser(notificationUsername);
notificationsDatabase = databaseProvider.provideNotificationsDao(notificationUsername);
if (!user.isBackgroundSync()) {
return;
}
try {
if (extras.containsKey(EXTRA_ENCRYPTED_DATA)) {
String encryptedStr = extras.getString(EXTRA_ENCRYPTED_DATA);
UserCrypto crypto = Crypto.forUser(mUserManager, notificationUsername);
TextDecryptionResult textDecryptionResult = crypto.decryptMessage(new CipherText(encryptedStr));
String decryptedStr = textDecryptionResult.getDecryptedData();
notificationData = tryParseNotificationModel(decryptedStr);
messageData = notificationData.getData();
}
} catch (Exception e) {
// can not deliver notification
if (!BuildConfig.DEBUG) {
EventBuilder eventBuilder = new EventBuilder().withTag("FCM_MU", TextUtils.isEmpty(notificationUsername) ? "EMPTY" : "NOT_EMPTY");
Timber.e(e, eventBuilder.toString());
}
}
if (notificationData == null || messageData == null) {
return;
}
final String messageId = messageData.getMessageId();
final String notificationBody = messageData.getBody();
NotificationSender notificationSender = messageData.getSender();
String sender = notificationSender.getSenderName();
if (TextUtils.isEmpty(sender)) {
sender = notificationSender.getSenderEmail();
}
boolean primaryUser = mUserManager.getUsername().equals(notificationUsername);
if (extras.containsKey(EXTRA_READ) && extras.getBoolean(EXTRA_READ)) {
removeNotification(user, messageId, primaryUser);
return;
}
boolean isQuickSnoozeEnabled = mUserManager.isSnoozeQuickEnabled();
boolean isScheduledSnoozeEnabled = mUserManager.isSnoozeScheduledEnabled();
if (!isQuickSnoozeEnabled && (!isScheduledSnoozeEnabled || shouldShowNotificationWhileScheduledSnooze(user))) {
sendNotification(user, messageId, notificationBody, sender, primaryUser);
}
}
}
stopForeground(true);
}
/**
* Remove the Notification with the given id and eventually recreate a notification with other
* unread Notifications from database
*
* @param user current logged {@link User}
* @param messageId String id of {@link Message} for delete relative {@link Notification}
*/
private void removeNotification(final User user,
final String messageId,
final boolean primaryUser) {
NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
// Cancel all the Status Bar Notifications
notificationManager.cancelAll();
// Remove the Notification from Database
notificationsDatabase.deleteByMessageId(messageId);
List<Notification> notifications = notificationsDatabase.findAllNotifications();
// Return if there are no more unreadNotifications
if (notifications.isEmpty()) return;
Message message = fetchMessage(user, messageId);
if (notifications.size() > 1) {
notificationServer.notifyMultipleUnreadEmail(mUserManager, user, notifications);
} else {
Notification notification = notifications.get(0);
notificationServer.notifySingleNewEmail(
mUserManager, user, message, messageId,
notification.getNotificationBody(),
notification.getNotificationTitle(), primaryUser
);
}
}
/**
* Show a Notification for a new email received.
*
* @param user current logged {@link User}
* @param messageId String id for retrieve the {@link Message} details
* @param notificationBody String body of the Notification
* @param sender String name of the sender of the email
*/
private void sendNotification(
final User user,
final String messageId,
@Nullable final String notificationBody,
final String sender,
final boolean primaryUser
) {
// Insert current Notification in Database
Notification notification = new Notification(messageId, sender, notificationBody != null ? notificationBody : "");
notificationsDatabase.insertNotification(notification);
List<Notification> notifications = notificationsDatabase.findAllNotifications();
Message message = fetchMessage(user, messageId);
if (notifications.size() > 1) {
notificationServer.notifyMultipleUnreadEmail(mUserManager, user, notifications);
} else {
notificationServer.notifySingleNewEmail(
mUserManager, user, message, messageId, notificationBody, sender, primaryUser
);
}
}
private Message fetchMessage(final User user, final String messageId) {
// Fetch message details if required by the current config
boolean fetchMessageDetails = user.isGcmDownloadMessageDetails();
Message message;
if (fetchMessageDetails) {
message = fetchMessageDetails(messageId);
} else {
message = fetchMessageMetadata(messageId);
}
if (message == null) {
// try to find the message in the local storage, maybe it was received from the event
message = messageDetailsRepository.findMessageById(messageId);
}
return message;
}
private Message fetchMessageMetadata(final String messageId) {
Message message = null;
try {
MessagesResponse messageResponse = mApi.fetchSingleMessageMetadata(messageId);
if (messageResponse != null) {
List<Message> messages = messageResponse.getMessages();
if (messages.size() > 0) {
message = messages.get(0);
}
if (message != null) {
Message savedMessage = messageDetailsRepository.findMessageById(message.getMessageId());
if (savedMessage != null) {
message.setInline(savedMessage.isInline());
}
message.setDownloaded(false);
messageDetailsRepository.saveMessageInDB(message);
} else {
// check if the message is already in local store
message = messageDetailsRepository.findMessageById(messageId);
}
}
} catch (Exception error) {
Logger.doLogException(TAG_FCM_INTENT_SERVICE, "error while fetching message detail", error);
}
return message;
}
private Message fetchMessageDetails(final String messageId) {
Message message = null;
try {
MessageResponse messageResponse = mApi.messageDetail(messageId);
message = messageResponse.getMessage();
Message savedMessage = messageDetailsRepository.findMessageById(messageId);
if (savedMessage != null) {
message.setInline(savedMessage.isInline());
}
message.setDownloaded(true);
messageDetailsRepository.saveMessageInDB(message);
} catch (Exception error) {
Logger.doLogException(TAG_FCM_INTENT_SERVICE, "error while fetching message detail", error);
}
return message;
}
private boolean shouldShowNotificationWhileScheduledSnooze(User user) {
Calendar rightNow = Calendar.getInstance();
SnoozeSettings snoozeSettings = mUserManager.getSnoozeSettings();
return !snoozeSettings.shouldSuppressNotification(rightNow);
}
private NotificationData tryParseNotificationModel(String decryptedStr) {
Gson gson = new Gson();
return gson.fromJson(decryptedStr, NotificationData.class);
}
}

View File

@ -19,10 +19,8 @@
package ch.protonmail.android.fcm
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import ch.protonmail.android.R
import com.google.firebase.messaging.FirebaseMessagingService
@ -39,6 +37,8 @@ public class PMFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pmRegistrationWorkerEnqueuer: PMRegistrationWorker.Enqueuer
@Inject
lateinit var processPushNotificationData: ProcessPushNotificationDataWorker.Enqueuer
override fun onNewToken(token: String) {
super.onNewToken(token)
@ -60,9 +60,7 @@ public class PMFirebaseMessagingService : FirebaseMessagingService() {
broadcastIntent.putExtras(bundle)
broadcastIntent.action = baseContext.getString(R.string.action_notification)
if (!LocalBroadcastManager.getInstance(baseContext).sendBroadcast(broadcastIntent)) {
val serviceIntent = Intent(broadcastIntent)
val comp = ComponentName(baseContext.packageName, FcmIntentService::class.java.name)
ContextCompat.startForegroundService(baseContext, serviceIntent.setComponent(comp))
processPushNotificationData(remoteMessage.data)
}
}
}

View File

@ -0,0 +1,245 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.fcm
import android.content.Context
import androidx.hilt.Assisted
import androidx.hilt.work.WorkerInject
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import ch.protonmail.android.activities.messageDetails.repository.MessageDetailsRepository
import ch.protonmail.android.api.ProtonMailApiManager
import ch.protonmail.android.api.models.DatabaseProvider
import ch.protonmail.android.api.models.User
import ch.protonmail.android.api.models.messages.receive.MessageResponse
import ch.protonmail.android.api.models.room.messages.Message
import ch.protonmail.android.api.models.room.notifications.Notification
import ch.protonmail.android.api.segments.event.AlarmReceiver
import ch.protonmail.android.core.QueueNetworkUtil
import ch.protonmail.android.core.UserManager
import ch.protonmail.android.crypto.UserCrypto
import ch.protonmail.android.domain.entity.Name
import ch.protonmail.android.fcm.models.PushNotification
import ch.protonmail.android.fcm.models.PushNotificationData
import ch.protonmail.android.servers.notification.NotificationServer
import ch.protonmail.android.utils.AppUtil
import me.proton.core.util.kotlin.EMPTY_STRING
import me.proton.core.util.kotlin.deserialize
import timber.log.Timber
import java.util.Calendar
import javax.inject.Inject
const val KEY_PUSH_NOTIFICATION_UID = "UID"
const val KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE = "encryptedMessage"
const val KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR = "ProcessPushNotificationDataError"
/**
* A worker that is responsible for processing the data payload of the received FCM push notifications.
*/
class ProcessPushNotificationDataWorker @WorkerInject constructor(
@Assisted context: Context,
@Assisted workerParameters: WorkerParameters,
private val notificationServer: NotificationServer,
private val alarmReceiver: AlarmReceiver,
private val queueNetworkUtil: QueueNetworkUtil,
private val userManager: UserManager,
private val databaseProvider: DatabaseProvider,
private val messageDetailsRepository: MessageDetailsRepository,
private val protonMailApiManager: ProtonMailApiManager
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
val sessionId = inputData.getString(KEY_PUSH_NOTIFICATION_UID)
val encryptedMessage = inputData.getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE)
if (sessionId.isNullOrEmpty() || encryptedMessage.isNullOrEmpty()) {
return Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Input data is missing")
)
}
if (!AppUtil.isAppInBackground()) {
alarmReceiver.setAlarm(applicationContext, true)
}
queueNetworkUtil.setCurrentlyHasConnectivity(true)
val notificationUsername = userManager.getUsernameBySessionId(sessionId)
if (notificationUsername.isNullOrEmpty()) {
// we do not show notifications for unknown/inactive users
return Result.failure(workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "User is unknown or inactive"))
}
val user = userManager.getUser(notificationUsername)
if (!user.isBackgroundSync) {
// we do not show notifications for users who have disabled background sync
return Result.failure(workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Background sync is disabled"))
}
var pushNotification: PushNotification? = null
var pushNotificationData: PushNotificationData? = null
try {
val userCrypto = UserCrypto(userManager, userManager.openPgp, Name(notificationUsername))
val textDecryptionResult = userCrypto.decryptMessage(encryptedMessage)
val decryptedData = textDecryptionResult.decryptedData
pushNotification = decryptedData.deserialize()
pushNotificationData = pushNotification.data
} catch (e: Exception) {
Timber.e(e, "Error with decryption or deserialization of the notification data")
}
if (pushNotification == null || pushNotificationData == null) {
return Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Decryption or deserialization error")
)
}
val messageId = pushNotificationData.messageId
val notificationBody = pushNotificationData.body
val notificationSender = pushNotificationData.sender
val sender = notificationSender?.let {
it.senderName.ifEmpty { it.senderAddress }
} ?: EMPTY_STRING
val primaryUser = userManager.username == notificationUsername
val isQuickSnoozeEnabled = userManager.isSnoozeQuickEnabled()
val isScheduledSnoozeEnabled = userManager.isSnoozeScheduledEnabled()
if (!isQuickSnoozeEnabled && (!isScheduledSnoozeEnabled || !shouldSuppressNotification())) {
sendNotification(user, messageId, notificationBody, sender, primaryUser)
}
return Result.success()
}
private fun sendNotification(
user: User,
messageId: String,
notificationBody: String,
sender: String,
primaryUser: Boolean
) {
// Insert current Notification in Database
val notificationsDatabase = databaseProvider.provideNotificationsDao(user.username)
val notification = Notification(messageId, sender, notificationBody)
notificationsDatabase.insertNotification(notification)
val notifications = notificationsDatabase.findAllNotifications()
val message = fetchMessage(user, messageId)
if (notifications.size > 1) {
notificationServer.notifyMultipleUnreadEmail(userManager, user, notifications)
} else {
notificationServer.notifySingleNewEmail(
userManager, user, message, messageId, notificationBody, sender, primaryUser
)
}
}
private fun fetchMessage(user: User, messageId: String): Message? {
// Fetch message details if required by the current config
val fetchMessageDetails = user.isGcmDownloadMessageDetails
return if (fetchMessageDetails) {
fetchMessageDetails(messageId)
} else {
fetchMessageMetadata(messageId)
}
?: messageDetailsRepository.findMessageById(messageId)
}
private fun fetchMessageMetadata(messageId: String): Message? {
var message: Message? = null
try {
val messageResponse = protonMailApiManager.fetchSingleMessageMetadata(messageId)
if (messageResponse != null) {
val messages = messageResponse.messages
if (messages.isNotEmpty()) {
message = messages[0]
}
if (message != null) {
val savedMessage = messageDetailsRepository.findMessageById(message.messageId!!)
if (savedMessage != null) {
message.isInline = savedMessage.isInline
}
message.isDownloaded = false
messageDetailsRepository.saveMessageInDB(message)
} else {
// check if the message is already in local store
message = messageDetailsRepository.findMessageById(messageId)
}
}
} catch (error: Exception) {
Timber.e(error, "error while fetching message metadata in ProcessPushNotificationDataWorker")
}
return message
}
private fun fetchMessageDetails(messageId: String): Message? {
var message: Message? = null
try {
val messageResponse: MessageResponse = protonMailApiManager.messageDetail(messageId)
message = messageResponse.message
val savedMessage = messageDetailsRepository.findMessageById(messageId)
if (savedMessage != null) {
message.isInline = savedMessage.isInline
}
message.isDownloaded = true
messageDetailsRepository.saveMessageInDB(message)
} catch (error: Exception) {
Timber.e(error, "error while fetching message detail in ProcessPushNotificationDataWorker")
}
return message
}
private fun shouldSuppressNotification(): Boolean {
val rightNow = Calendar.getInstance()
return userManager.snoozeSettings?.shouldSuppressNotification(rightNow) ?: false
}
class Enqueuer @Inject constructor(
private val workManager: WorkManager
) {
operator fun invoke(pushNotificationData: Map<String, String>) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = Data.Builder()
.putAll(pushNotificationData)
.build()
val workRequest = OneTimeWorkRequestBuilder<ProcessPushNotificationDataWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.build()
workManager.enqueue(workRequest)
}
}
}

View File

@ -1,35 +0,0 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.fcm.models
import com.google.gson.annotations.SerializedName
data class NotificationEncryptedData(
@SerializedName("title") val title: String? = null,
@SerializedName("subtitle") val subtitle: String? = null,
@SerializedName("body") val body: String? = null,
@SerializedName("vibrate") val vibrate: Int = 0,
@SerializedName("sound") val sound: Int = 0,
@SerializedName("largeIcon") val largeIcon: String? = null,
@SerializedName("smallIcon") val smallIcon: String? = null,
@SerializedName("badge") val badge: Int = 0,
@SerializedName("messageId") val messageId: String? = null,
@SerializedName("customId") val customId: String? = null,
@SerializedName("sender") val sender: NotificationSender? = null
)

View File

@ -18,9 +18,12 @@
*/
package ch.protonmail.android.fcm.models
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class NotificationSender(
@SerializedName("Address") val senderEmail: String? = null,
@SerializedName("Name") val senderName: String? = null
)
@Serializable
data class PushNotification(
@SerialName("type") val type: String,
@SerialName("version") val version: Int,
@SerialName("data") val data: PushNotificationData?
)

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.fcm.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PushNotificationData(
@SerialName("title") val title: String,
@SerialName("subtitle") val subtitle: String,
@SerialName("body") val body: String,
@SerialName("vibrate") val vibrate: Int,
@SerialName("sound") val sound: Int,
@SerialName("largeIcon") val largeIcon: String,
@SerialName("smallIcon") val smallIcon: String,
@SerialName("badge") val badge: Int,
@SerialName("messageId") val messageId: String,
@SerialName("customId") val customId: String,
@SerialName("sender") val sender: PushNotificationSender?
)

View File

@ -18,10 +18,12 @@
*/
package ch.protonmail.android.fcm.models
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class NotificationData(
@SerializedName("type") val type: String? = null,
@SerializedName("version") val version: Int = 0,
@SerializedName("data") val data: NotificationEncryptedData? = null
)
@Serializable
data class PushNotificationSender(
@SerialName("Address") val senderAddress: String,
@SerialName("Name") val senderName: String,
@SerialName("Group") val senderGroup: String
)

View File

@ -42,7 +42,6 @@ import ch.protonmail.android.R
import ch.protonmail.android.activities.EXTRA_SWITCHED_TO_USER
import ch.protonmail.android.activities.EXTRA_SWITCHED_USER
import ch.protonmail.android.activities.composeMessage.ComposeMessageActivity
import ch.protonmail.android.activities.guest.LoginActivity
import ch.protonmail.android.activities.mailbox.MailboxActivity
import ch.protonmail.android.activities.messageDetails.MessageDetailsActivity
import ch.protonmail.android.api.models.User
@ -57,6 +56,7 @@ import ch.protonmail.android.utils.buildReplyIntent
import ch.protonmail.android.utils.buildTrashIntent
import ch.protonmail.android.utils.extensions.getColorCompat
import ch.protonmail.android.utils.extensions.showToast
import javax.inject.Inject
import ch.protonmail.android.api.models.room.notifications.Notification as RoomNotification
// region constants
@ -73,10 +73,10 @@ const val EXTRA_USERNAME = "username"
// endregion
/**
* Created by Kamil Rajtar on 13.07.18.
* A class that is responsible for creating notification channels, and creating and showing notifications.
*/
class NotificationServer(
class NotificationServer @Inject constructor(
private val context: Context,
private val notificationManager: NotificationManager
) : INotificationServer {
@ -162,11 +162,13 @@ class NotificationServer(
return CHANNEL_ID_ONGOING_OPS
}
override fun notifyVerificationNeeded(user: User?,
messageTitle: String,
messageId: String,
messageInline: Boolean,
messageAddressId: String) {
override fun notifyVerificationNeeded(
user: User?,
messageTitle: String,
messageId: String,
messageInline: Boolean,
messageAddressId: String
) {
val inboxStyle = NotificationCompat.BigTextStyle()
inboxStyle.setBigContentTitle(context.getString(R.string.verification_needed))
inboxStyle.bigText(String.format(
@ -228,8 +230,8 @@ class NotificationServer(
val channelId = createAccountChannel()
val mBuilder = NotificationCompat.Builder(context,
channelId).setSmallIcon(R.drawable.notification_icon)
val mBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.notification_icon)
.setColor(ContextCompat.getColor(context, R.color.ocean_blue))
.setStyle(inboxStyle)
.setLights(ContextCompat.getColor(context, R.color.light_indicator),
@ -241,8 +243,12 @@ class NotificationServer(
notificationManager.notify(3, notification)
}
override fun notifyAboutAttachment(filename: String, uri: Uri,
mimeType: String?, showNotification: Boolean) {
override fun notifyAboutAttachment(
filename: String,
uri: Uri,
mimeType: String?,
showNotification: Boolean
) {
val channelId = createAttachmentsChannel()
val mBuilder = NotificationCompat.Builder(context, channelId)
@ -560,4 +566,3 @@ class NotificationServer(
notificationManager.notify(user.username.hashCode() + NOTIFICATION_ID_SENDING_FAILED, notification)
}
}

View File

@ -0,0 +1,463 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.fcm
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import ch.protonmail.android.activities.messageDetails.repository.MessageDetailsRepository
import ch.protonmail.android.api.ProtonMailApiManager
import ch.protonmail.android.api.models.DatabaseProvider
import ch.protonmail.android.api.models.User
import ch.protonmail.android.api.models.room.messages.Message
import ch.protonmail.android.api.models.room.notifications.Notification
import ch.protonmail.android.api.segments.event.AlarmReceiver
import ch.protonmail.android.core.QueueNetworkUtil
import ch.protonmail.android.core.UserManager
import ch.protonmail.android.crypto.UserCrypto
import ch.protonmail.android.fcm.models.PushNotification
import ch.protonmail.android.fcm.models.PushNotificationData
import ch.protonmail.android.fcm.models.PushNotificationSender
import ch.protonmail.android.servers.notification.NotificationServer
import ch.protonmail.android.utils.AppUtil
import me.proton.core.util.kotlin.deserialize
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.just
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.spyk
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
/**
* Tests the functionality of [ProcessPushNotificationDataWorker].
*/
class ProcessPushNotificationDataWorkerTest {
@RelaxedMockK
private lateinit var context: Context
@RelaxedMockK
private lateinit var userManager: UserManager
@RelaxedMockK
private lateinit var workerParameters: WorkerParameters
@RelaxedMockK
private lateinit var workManager: WorkManager
@MockK
private lateinit var alarmReceiver: AlarmReceiver
@MockK
private lateinit var databaseProvider: DatabaseProvider
@MockK
private lateinit var messageDetailsRepository: MessageDetailsRepository
@MockK
private lateinit var notificationServer: NotificationServer
@MockK
private lateinit var protonMailApiManager: ProtonMailApiManager
@MockK
private lateinit var queueNetworkUtil: QueueNetworkUtil
private lateinit var processPushNotificationDataWorker: ProcessPushNotificationDataWorker
private lateinit var processPushNotificationDataWorkerEnqueuer: ProcessPushNotificationDataWorker.Enqueuer
@Before
fun setUp() {
MockKAnnotations.init(this, relaxUnitFun = true)
mockkConstructor(UserCrypto::class)
mockkStatic(AppUtil::class)
mockkStatic("me.proton.core.util.kotlin.SerializationUtilsKt")
processPushNotificationDataWorker = spyk(
ProcessPushNotificationDataWorker(
context,
workerParameters,
notificationServer,
alarmReceiver,
queueNetworkUtil,
userManager,
databaseProvider,
messageDetailsRepository,
protonMailApiManager
),
recordPrivateCalls = true
)
processPushNotificationDataWorkerEnqueuer = ProcessPushNotificationDataWorker.Enqueuer(
workManager
)
}
@After
fun tearDown() {
unmockkConstructor(UserCrypto::class)
unmockkStatic(AppUtil::class)
unmockkStatic("me.proton.core.util.kotlin.SerializationUtilsKt")
}
@Test
fun verifyWorkIsEnqueuedWhenEnqueuerIsInvoked() {
// given
val mockPushNotificationData = mockk<Map<String, String>>(relaxed = true)
// when
processPushNotificationDataWorkerEnqueuer(mockPushNotificationData)
// then
verify { workManager.enqueue(any<OneTimeWorkRequest>()) }
}
@Test
fun returnFailureIfInputDataIsMissing() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns ""
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns null
}
val expectedResult = ListenableWorker.Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Input data is missing")
)
// when
val workResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workResult)
}
}
@Test
fun verifyAlarmIsSetIfAppIsNotInBackground() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
// when
processPushNotificationDataWorker.doWork()
// then
verify { alarmReceiver.setAlarm(any(), true) }
}
}
@Test
fun verifySettingHasConnectivityToTrue() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
// when
processPushNotificationDataWorker.doWork()
// then
verify { queueNetworkUtil.setCurrentlyHasConnectivity(true) }
}
}
@Test
fun returnFailureIfUserIsUnknownOrInactive() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
every { userManager.getUsernameBySessionId("uid") } returns null
val expectedResult = ListenableWorker.Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "User is unknown or inactive")
)
// when
val workerResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workerResult)
}
}
@Test
fun returnFailureIfBackgroundSyncIsDisabled() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
every { userManager.getUsernameBySessionId("uid") } returns "username"
every { userManager.getUser("username") } returns mockk {
every { isBackgroundSync } returns false
}
val expectedResult = ListenableWorker.Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Background sync is disabled")
)
// when
val workerResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workerResult)
}
}
@Test
fun returnFailureIfDecryptionFails() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
every { userManager.getUsernameBySessionId("uid") } returns "username"
every { userManager.getUser("username") } returns mockk {
every { isBackgroundSync } returns true
}
every { userManager.openPgp } returns mockk(relaxed = true)
every { anyConstructed<UserCrypto>().decryptMessage(any()) } throws IllegalStateException()
val expectedResult = ListenableWorker.Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Decryption or deserialization error")
)
// when
val workerResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workerResult)
}
}
@Test
fun returnFailureIfDeserializationFails() {
runBlocking {
// given
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
every { userManager.getUsernameBySessionId("uid") } returns "username"
every { userManager.getUser("username") } returns mockk {
every { isBackgroundSync } returns true
}
every { userManager.openPgp } returns mockk(relaxed = true)
every { anyConstructed<UserCrypto>().decryptMessage(any()) } returns mockk {
every { decryptedData } returns "decryptedData"
}
every { "decryptedData".deserialize<PushNotification>() } returns mockk {
every { data } returns null
}
val expectedResult = ListenableWorker.Result.failure(
workDataOf(KEY_PROCESS_PUSH_NOTIFICATION_DATA_ERROR to "Decryption or deserialization error")
)
// when
val workerResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workerResult)
}
}
private fun mockForCallingSendNotificationSuccessfully(): Pair<PushNotificationSender, PushNotificationData> {
every { workerParameters.inputData } returns mockk {
every { getString(KEY_PUSH_NOTIFICATION_UID) } returns "uid"
every { getString(KEY_PUSH_NOTIFICATION_ENCRYPTED_MESSAGE) } returns "encryptedMessage"
}
every { AppUtil.isAppInBackground() } returns false
every { userManager.getUsernameBySessionId("uid") } returns "username"
every { userManager.getUser("username") } returns mockk {
every { isBackgroundSync } returns true
every { username } returns "username"
}
every { userManager.openPgp } returns mockk(relaxed = true)
every { anyConstructed<UserCrypto>().decryptMessage(any()) } returns mockk {
every { decryptedData } returns "decryptedData"
}
val mockNotificationSender = mockk<PushNotificationSender> {
every { senderName } returns ""
every { senderAddress } returns "senderAddress"
}
val mockNotificationEncryptedData = mockk<PushNotificationData> {
every { messageId } returns "messageId"
every { body } returns "body"
every { sender } returns mockNotificationSender
}
every { "decryptedData".deserialize<PushNotification>() } returns mockk {
every { data } returns mockNotificationEncryptedData
}
every { userManager.username } returns "username"
every { userManager.isSnoozeQuickEnabled() } returns false
every { userManager.isSnoozeScheduledEnabled() } returns true
every { processPushNotificationDataWorker invokeNoArgs "shouldSuppressNotification" } returns false
return Pair(mockNotificationSender, mockNotificationEncryptedData)
}
@Test
fun verifyCorrectMethodInvocationAfterDecryptionAndDeserializationSucceedsWhenSnoozingNotificationsIsNotActive() {
runBlocking {
// given
val (mockNotificationSender, mockNotificationEncryptedData) = mockForCallingSendNotificationSuccessfully()
justRun { processPushNotificationDataWorker invoke "sendNotification" withArguments listOf(any<User>(), any<String>(), any<String>(), any<String>(), any<Boolean>()) }
// when
processPushNotificationDataWorker.doWork()
// then
verifyOrder {
mockNotificationEncryptedData.messageId
mockNotificationEncryptedData.body
mockNotificationEncryptedData.sender
mockNotificationSender.senderName
mockNotificationSender.senderAddress
userManager.username
userManager.isSnoozeQuickEnabled()
userManager.isSnoozeScheduledEnabled()
processPushNotificationDataWorker invokeNoArgs "shouldSuppressNotification"
}
}
}
@Test
fun verifyNotifySingleNewEmailIsCalledWithCorrectParametersWhenThereIsOneNotificationInTheDB() {
runBlocking {
// given
mockForCallingSendNotificationSuccessfully()
val mockNotification = mockk<Notification>()
every { databaseProvider.provideNotificationsDao("username") } returns mockk(relaxed = true) {
every { findAllNotifications() } returns listOf(mockNotification)
}
val mockMessage = mockk<Message>()
every { processPushNotificationDataWorker invoke "fetchMessage" withArguments listOf(any<User>(), any<String>()) } returns mockMessage
every { notificationServer.notifySingleNewEmail(any(), any(), any(), any(), any(), any(), any()) } just runs
// when
processPushNotificationDataWorker.doWork()
// then
val userManagerSlot = slot<UserManager>()
val userSlot = slot<User>()
val messageSlot = slot<Message>()
val messageIdSlot = slot<String>()
val notificationBodySlot = slot<String>()
val senderSlot = slot<String>()
val primaryUserSlot = slot<Boolean>()
verify {
notificationServer.notifySingleNewEmail(capture(userManagerSlot), capture(userSlot), capture(messageSlot), capture(messageIdSlot), capture(notificationBodySlot), capture(senderSlot), capture(primaryUserSlot))
}
assertEquals(userManager, userManagerSlot.captured)
assertEquals(userManager.getUser("username"), userSlot.captured)
assertEquals(mockMessage, messageSlot.captured)
assertEquals("messageId", messageIdSlot.captured)
assertEquals("body", notificationBodySlot.captured)
assertEquals("senderAddress", senderSlot.captured)
assertEquals(true, primaryUserSlot.captured)
}
}
@Test
fun verifyNotifyMultipleUnreadEmailIsCalledWithCorrectParametersWhenThereAreMoreThanOneNotificationsInTheDB() {
runBlocking {
// given
mockForCallingSendNotificationSuccessfully()
val mockNotification1 = mockk<Notification>()
val mockNotification2 = mockk<Notification>()
val unreadNotifications = listOf(mockNotification1, mockNotification2)
every { databaseProvider.provideNotificationsDao("username") } returns mockk(relaxed = true) {
every { findAllNotifications() } returns unreadNotifications
}
val mockMessage = mockk<Message>()
every { processPushNotificationDataWorker invoke "fetchMessage" withArguments listOf(any<User>(), any<String>()) } returns mockMessage
every { notificationServer.notifyMultipleUnreadEmail(any(), any(), any()) } just runs
// when
processPushNotificationDataWorker.doWork()
// then
val userManagerSlot = slot<UserManager>()
val userSlot = slot<User>()
val unreadNotificationsSlot = slot<List<Notification>>()
verify {
notificationServer.notifyMultipleUnreadEmail(capture(userManagerSlot), capture(userSlot), capture(unreadNotificationsSlot))
}
assertEquals(userManager, userManagerSlot.captured)
assertEquals(userManager.getUser("username"), userSlot.captured)
assertEquals(unreadNotifications, unreadNotificationsSlot.captured)
}
}
@Test
fun returnSuccessWhenNotificationWasSent() {
runBlocking {
// given
mockForCallingSendNotificationSuccessfully()
val mockNotification = mockk<Notification>()
every { databaseProvider.provideNotificationsDao("username") } returns mockk(relaxed = true) {
every { findAllNotifications() } returns listOf(mockNotification)
}
val mockMessage = mockk<Message>()
every { processPushNotificationDataWorker invoke "fetchMessage" withArguments listOf(any<User>(), any<String>()) } returns mockMessage
every { notificationServer.notifySingleNewEmail(any(), any(), any(), any(), any(), any(), any()) } just runs
val expectedResult = ListenableWorker.Result.success()
// when
val workerResult = processPushNotificationDataWorker.doWork()
// then
assertEquals(expectedResult, workerResult)
}
}
}