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

Referral Notifications - FCM Push Notifications #2667

Merged
merged 19 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@
android:usesCleartextTraffic="true"
android:theme="@style/AppBaseTheme">

<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/fcm_default_notification_channel" />

<activity
android:label="@string/application_name"
android:exported="true"
Expand Down Expand Up @@ -548,6 +552,14 @@
android:name="com.dimagi.android.zebraprinttool.PrintReceiverActivity"
tools:node="merge"/>

<service
android:name="org.commcare.services.CommCareFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

</application>

</manifest>
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ dependencies {
implementation 'com.jakewharton.rxbinding2:rxbinding:2.0.0'
implementation 'com.journeyapps:zxing-android-embedded:3.6.0'
implementation 'com.google.firebase:firebase-analytics:17.5.0'
implementation 'com.google.firebase:firebase-messaging:21.1.0'
implementation 'com.google.firebase:firebase-crashlytics:17.2.1'
implementation 'androidx.legacy:legacy-support-core-ui:1.0.0'
implementation 'com.duolingo.open:rtl-viewpager:2.0.0'
Expand Down
2 changes: 2 additions & 0 deletions app/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -450,4 +450,6 @@
<string name="dependency_missing_dialog_message_plural" cc:translatable="true">CommCare needs below applications to be installed on your device before you can continue-</string>
<string name="dependency_missing_dialog_go_to_store" cc:translatable="true">Install from Store</string>
<string name="dependency_missing_dialog_playstore_not_found" cc:translatable="true">No Play Store found on your device</string>
<string name="fcm_notification">FCM Notification</string>
<string name="fcm_default_notification_channel">notification-channel-server-communications</string>
</resources>
17 changes: 13 additions & 4 deletions app/src/org/commcare/CommCareApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import com.google.firebase.analytics.FirebaseAnalytics;

Expand Down Expand Up @@ -107,6 +106,7 @@
import org.commcare.utils.CrashUtil;
import org.commcare.utils.DeviceIdentifier;
import org.commcare.utils.FileUtil;
import org.commcare.utils.FirebaseMessagingUtil;
import org.commcare.utils.GlobalConstants;
import org.commcare.utils.MarkupUtil;
import org.commcare.utils.MultipleAppsUtil;
Expand All @@ -115,7 +115,6 @@
import org.commcare.utils.SessionStateUninitException;
import org.commcare.utils.SessionUnavailableException;
import org.commcare.views.widgets.CleanRawMedia;
import org.conscrypt.Conscrypt;
import org.javarosa.core.model.User;
import org.javarosa.core.reference.ReferenceManager;
import org.javarosa.core.reference.RootTranslator;
Expand All @@ -126,12 +125,10 @@
import org.javarosa.core.util.externalizable.PrototypeFactory;

import java.io.File;
import java.security.Security;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import javax.annotation.Nullable;
Expand Down Expand Up @@ -250,6 +247,8 @@ public void onCreate() {

LocalePreferences.saveDeviceLocale(Locale.getDefault());
GraphUtil.setLabelCharacterLimit(getResources().getInteger(R.integer.graph_label_char_limit));

FirebaseMessagingUtil.verifyToken();
}

protected void attachISRGCert() {
Expand Down Expand Up @@ -954,6 +953,15 @@ public CommCareSessionService getSession() {
}
}

public static boolean isSessionActive() {
try {
return CommCareApplication.instance().getSession() != null;
}
catch (SessionUnavailableException e){
return false;
}
}

public UserKeyRecord getRecordForCurrentUser() {
return getSession().getUserKeyRecord();
}
Expand Down Expand Up @@ -1183,4 +1191,5 @@ public AndroidPackageUtils getAndroidPackageUtils() {
public boolean isNsdServicesEnabled() {
return true;
}

}
20 changes: 14 additions & 6 deletions app/src/org/commcare/heartbeat/HeartbeatRequester.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package org.commcare.heartbeat;


import static org.commcare.utils.FirebaseMessagingUtil.FCM_TOKEN;
import static org.commcare.utils.FirebaseMessagingUtil.FCM_TOKEN_TIME;

import android.util.Log;

import org.commcare.CommCareApplication;
Expand All @@ -11,6 +15,7 @@
import org.commcare.preferences.ServerUrls;
import org.commcare.util.LogTypes;
import org.commcare.utils.CommCareUtil;
import org.commcare.utils.FirebaseMessagingUtil;
import org.commcare.utils.SessionUnavailableException;
import org.commcare.utils.StorageUtils;
import org.commcare.utils.SyncDetailCalculations;
Expand All @@ -20,7 +25,6 @@

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.TimeZone;

import androidx.work.WorkManager;
Expand Down Expand Up @@ -64,9 +68,13 @@ public Multimap<String, String> getRequestParams() {
params.put(CC_VERSION, ReportingUtils.getCommCareVersionString());
params.put(QUARANTINED_FORMS_PARAM, String.valueOf(StorageUtils.getNumQuarantinedForms()));
params.put(UNSENT_FORMS_PARAM, String.valueOf(StorageUtils.getNumUnsentForms()));
params.put(LAST_SYNC_TIME_PARAM, getISO8601FormattedLastSyncTime());
params.put(LAST_SYNC_TIME_PARAM, convertTimeInMsToISO8601(SyncDetailCalculations.getLastSyncTime()));
params.put(CURRENT_DRIFT, String.valueOf(DriftHelper.getCurrentDrift()));
params.put(MAX_DRIFT_SINCE_LAST_HEARTBEAT, String.valueOf(DriftHelper.getMaxDriftSinceLastHeartbeat()));
//TODO: Encode the FCM registration token
params.put(FCM_TOKEN, FirebaseMessagingUtil.getFCMToken());
// TODO: Convert the Date to ISO 8601 format
params.put(FCM_TOKEN_TIME, convertTimeInMsToISO8601(FirebaseMessagingUtil.getFCMTokenTime()));
return params;
}

Expand All @@ -75,14 +83,14 @@ public AuthInfo getAuth() {
return new AuthInfo.CurrentAuth();
}

private static String getISO8601FormattedLastSyncTime() {
long lastSyncTime = SyncDetailCalculations.getLastSyncTime();
if (lastSyncTime == 0) {
// TODO: Move this method to DateUtils
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending todo

private static String convertTimeInMsToISO8601(long ms) {
if (ms == 0) {
return "";
} else {
DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'");
df.setTimeZone(TimeZone.getTimeZone("UTC"));
return df.format(lastSyncTime);
return df.format(ms);
}
}

Expand Down
16 changes: 15 additions & 1 deletion app/src/org/commcare/preferences/HiddenPreferences.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ public class HiddenPreferences {
private static final String BYPASS_PRE_UPDATE_SYNC = "bypass_pre_update_sync";
private static final String DISABLE_BACKGROUND_WORK_TIME = "disable-background-work-time";


private static final long NO_OF_HOURS_TO_WAIT_TO_RESUME_BACKGROUND_WORK = 36;

// This is to be used by CommCareFirebaseMessagingService to schedule a sync after the next Login
public final static String PENDING_SYNC_REQUEST_FROM_SERVER = "pending-sync-request-from-server";

private static final String ENABLE_ANDROID_WINDOW_SECURE_FLAG = "cc-enable-android-window-secure-flag";



/**
* @return How many seconds should a user session remain open before expiring?
*/
Expand Down Expand Up @@ -552,6 +556,16 @@ public static void markRawMediaCleanUpComplete() {
.edit().putBoolean(RAW_MEDIA_CLEANUP_COMPLETE, true).apply();
}

public static boolean isPendingSyncRequestFromServer() {
return PreferenceManager.getDefaultSharedPreferences(CommCareApplication.instance())
.getBoolean(PENDING_SYNC_REQUEST_FROM_SERVER, false);
}
public static void setPendingSyncRequestFromServer(boolean syncNeeded) {
PreferenceManager.getDefaultSharedPreferences(CommCareApplication.instance()).edit()
.putBoolean(PENDING_SYNC_REQUEST_FROM_SERVER, syncNeeded)
.apply();
}

public static boolean isFlagSecureEnabled() {
return DeveloperPreferences.doesPropertyMatch(ENABLE_ANDROID_WINDOW_SECURE_FLAG, PrefValues.NO, PrefValues.YES);
}
Expand Down
148 changes: 148 additions & 0 deletions app/src/org/commcare/services/CommCareFirebaseMessagingService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package org.commcare.services;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;

import androidx.core.app.NotificationCompat;

import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;

import org.commcare.CommCareNoficationManager;
import org.commcare.activities.DispatchActivity;
import org.commcare.dalvik.R;
import org.commcare.util.LogTypes;
import org.commcare.utils.FirebaseMessagingUtil;
import org.javarosa.core.services.Logger;
import org.joda.time.DateTime;

import java.util.Map;

/**
* This service responds to any events/messages from Firebase Cloud Messaging. The intention is to
* offer an entry point for any message from FCM and trigger the necessary steps based on the action
* key.
*/
public class CommCareFirebaseMessagingService extends FirebaseMessagingService {

private final static int FCM_NOTIFICATION = R.string.fcm_notification;
private enum ActionTypes{
SYNC,
INVALID
}

/**
* Upon receiving a new message from FCM, CommCare needs to:
* 1) Trigger the notification if the message contains a Notification object. Note that the
* presence of a Notification object causes the onMessageReceived to not be called when the
* app is in the background, which means that the data object won't be processed from here
* 2) Verify if the message contains a data object and trigger the necessary steps according
* to the action it carries
*
*/
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
Logger.log(LogTypes.TYPE_FCM, "Message received: " + remoteMessage.getMessageId());
Map<String, String> payloadData = remoteMessage.getData();
RemoteMessage.Notification payloadNotification = remoteMessage.getNotification();
Comment on lines +48 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a notification contains both data and notification object ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. My understanding is that the main difference is when the app is in the background, the SDK doesn't call onMessageReceived only triggers the notification, i.e. if we want the sync to happen regardless of whether the app is in the foreground or background, we shouldn't have a notification object in the message

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't completely understand. How do we handle the sync notifcation when the app is in background ? Do we just discard the notification ? Or it's routed thorough a different pathway ?

Also when onMessageReceived gets called (app is in foreground), It seems like we can only have one of notification or data payload. In that case it might make more sense to rearrange the code flow as -

if (payloadNotification != null) {
            showNotification(payloadNotification);
} else if (payloadData.size() != 0){
     /// process payloadData
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bumping

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't completely understand. How do we handle the sync notifcation when the app is in background ? Do we just discard the notification ? Or it's routed thorough a different pathway ?

Yeah, this is a bit tricky. So, when the app is in the background and the message contains a notification object, Firebase SDK takes care of delivering the notification to the Notification Drawer. In case the message also contains a data object, Firebase SDK adds it to the extras of the intent of the launcher activity of the app. This is the default behaviour, so when the user taps the notification it opens the app launcher with the data object as an extra.

Also when onMessageReceived gets called (app is in foreground), It seems like we can only have one of notification or data payload.

We can have both, but in this case because we want to trigger the sync when the app is in the background too, we are having messages with only the data object

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case the message also contains a data object, Firebase SDK adds it to the extras of the intent of the launcher activity of the app. This is the default behaviour, so when the user taps the notification it opens the app launcher with the data object as an extra.

That means we should be handling these intents on app startup right ? Or they by default gets delivered to this messaging service when user clicks on notification and pull the app in foreground ?

Copy link
Contributor Author

@avazirna avazirna Jul 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shubham1g5 This is supposed to be handled on app startup but it's not yet implemented because our current implementation on HQ doesn't trigger FCM messages with both notification and data objects (see image below). So, the intention is to incorporate that on a later stage depending on the feedback we get as well, however, not in the scope of this work.
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not we still need to handle only data messages that gets send when app is in background ? if not, How does the app handle sync data messages that gets delivered to the phone when the CC is in background ? For ex, my current understanding is -

  1. We trigger a notification with Action 'Background Sync' and type data messages
  2. This notification gets delivered to phone when CC is in background. Therefore Firebase adds the notification to the notification drawer ? (Not sure if it's true for only data messages)
  3. On clicking notification, Firebase launches CC with the intent containing data action. But since we are not handling intents with data payload, background sync doesn't get triggered.

Does it sound right to you ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the message doesn't contain a notification object and the app is in the background, the message is delivered to the FCM Service, so the onMessageDelivered is called. More about this can be found here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, thanks for confirming.


if (payloadNotification != null) {
showNotification(payloadNotification);
}

// Check if the message contains a data object, there is no further action if not
if (payloadData.size() == 0){
return;
}

FCMMessageData fcmMessageData = new FCMMessageData(payloadData);

switch(fcmMessageData.action){
case SYNC -> {} // trigger sync for fcmMessageData
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should call setPendingSyncRequestFromServer here ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was done on #2680 in this commit

default ->
Logger.log(LogTypes.TYPE_FCM, "Invalid FCM action");
}
}

@Override
public void onNewToken(String token) {
// TODO: Remove the token from the log
Logger.log(LogTypes.TYPE_FCM, "New registration token was generated"+token);
FirebaseMessagingUtil.updateFCMToken(token);
}


/**
* This method purpose is to show notifications to the user when the app is in the foreground.
* When the app is in the background, FCM is responsible for notifying the user
*
*/
private void showNotification(RemoteMessage.Notification notification) {
String notificationTitle = notification.getTitle();
String notificationText = notification.getBody();
NotificationManager mNM = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);

Intent i = new Intent(this, DispatchActivity.class);
i.setAction(Intent.ACTION_MAIN);
i.addCategory(Intent.CATEGORY_LAUNCHER);

PendingIntent contentIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
contentIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
else
contentIntent = PendingIntent.getActivity(this, 0, i, 0);

NotificationCompat.Builder fcmNotification = new NotificationCompat.Builder(this,
CommCareNoficationManager.NOTIFICATION_CHANNEL_SERVER_COMMUNICATIONS_ID)
.setContentTitle(notificationTitle)
.setContentText(notificationText)
.setContentIntent(contentIntent)
.setSmallIcon(R.drawable.notification)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setWhen(System.currentTimeMillis());

mNM.notify(FCM_NOTIFICATION, fcmNotification.build());
}

/**
* This class is to facilitate handling the FCM Message Data object. It should contain all the
* necessary checks and transformations
*/
public class FCMMessageData {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: best to put this model in a new file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shubham1g5 This was done on #2680 in this commit

private ActionTypes action;
private String username;
private String domain;
private DateTime creationTime;

private FCMMessageData(Map<String, String> payloadData){
this.action = getActionType(payloadData.get("action"));
this.username = payloadData.get("username");
this.domain = payloadData.get("domain");
this.creationTime = convertISO8601ToDateTime(payloadData.get("created_at"));
}

private DateTime convertISO8601ToDateTime(String timeInISO8601) {
if (timeInISO8601 == null){
return null;
}
return new DateTime(timeInISO8601);
}

private ActionTypes getActionType(String action) {
if (action == null) {
return ActionTypes.INVALID;
}

switch (action.toUpperCase()) {
case "SYNC" -> {
return ActionTypes.SYNC;
}
default -> {
return ActionTypes.INVALID;
}
}
}
}
}
Loading