Skip to content

Commit 9ebfd7d

Browse files
authored
SDKS-3684 Handle push device token updates (#471)
* SDKS-3684 Handle push device token updates * SDKS-3684 Handle push device token updates * Addressing comments from Andy * Updating copyright, enhancing unit tests * Minor change to align behavior with iOS implementation * SDKS-3961 Update logic to handle push device token updates * Fixing issue with unit tests
1 parent fa2e842 commit 9ebfd7d

37 files changed

+2175
-136
lines changed

forgerock-authenticator/src/main/java/org/forgerock/android/auth/AuthenticatorManager.java

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020 - 2025 Ping Identity. All rights reserved.
2+
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -21,18 +21,20 @@
2121
import org.forgerock.android.auth.exception.MechanismCreationException;
2222
import org.forgerock.android.auth.exception.MechanismParsingException;
2323
import org.forgerock.android.auth.exception.MechanismPolicyViolationException;
24+
import org.forgerock.android.auth.exception.MechanismUpdatePushTokenException;
25+
import org.forgerock.android.auth.exception.PushMechanismException;
2426
import org.forgerock.android.auth.policy.FRAPolicy;
2527

28+
import java.util.ArrayList;
2629
import java.util.Collections;
2730
import java.util.List;
2831
import java.util.Map;
32+
import java.util.concurrent.atomic.AtomicInteger;
2933

3034
class AuthenticatorManager {
3135

3236
/** The Storage client. */
3337
private StorageClient storageClient;
34-
/** The FCM Device token. */
35-
private String deviceToken;
3638
/** The Application Context. */
3739
private Context context;
3840
/** The Oath Factory responsible to build Oath mechanisms. */
@@ -43,17 +45,19 @@ class AuthenticatorManager {
4345
private NotificationFactory notificationFactory;
4446
/** The Policy Evaluator is used to enforce policy compliance. */
4547
private FRAPolicyEvaluator policyEvaluator;
48+
/** The Device token manager is used to manage the device token. **/
49+
private PushDeviceTokenManager pushDeviceTokenManager;
4650

4751
private static final String TAG = AuthenticatorManager.class.getSimpleName();
4852

4953
AuthenticatorManager(Context context, StorageClient storageClient, FRAPolicyEvaluator policyEvaluator,
5054
String deviceToken) {
5155
this.context = context;
5256
this.storageClient = storageClient;
53-
this.deviceToken = deviceToken;
5457
this.policyEvaluator = policyEvaluator;
5558
this.oathFactory = new OathFactory(context, storageClient);
56-
this.pushFactory = new PushFactory(context, storageClient, deviceToken);
59+
this.pushDeviceTokenManager = new PushDeviceTokenManager(context, storageClient, deviceToken);
60+
this.pushFactory = new PushFactory(context, storageClient, pushDeviceTokenManager);
5761
this.notificationFactory = new NotificationFactory(storageClient);
5862

5963
OathCodeGenerator.getInstance(storageClient);
@@ -63,7 +67,7 @@ class AuthenticatorManager {
6367
void createMechanismFromUri(String uri, FRAListener<Mechanism> listener) {
6468
Logger.debug(TAG, "Creating new mechanism from URI: %s", uri);
6569
if(uri.startsWith(Mechanism.PUSH)) {
66-
if(deviceToken != null) {
70+
if(pushDeviceTokenManager.getDeviceTokenId() != null) {
6771
pushFactory.createFromUri(uri, listener);
6872
} else {
6973
Logger.warn(TAG, "Attempt to add a Push mechanism has failed. " +
@@ -251,25 +255,81 @@ PushNotification getNotificationByMessageId(String messageId) {
251255
return storageClient.getNotificationByMessageId(messageId);
252256
}
253257

254-
void registerForRemoteNotifications(String newDeviceToken) throws AuthenticatorException {
255-
if(this.deviceToken == null) {
256-
this.deviceToken = newDeviceToken;
257-
this.pushFactory = new PushFactory(context, storageClient, newDeviceToken);
258-
this.notificationFactory = new NotificationFactory(storageClient);
259-
PushResponder.getInstance(storageClient);
258+
void registerForRemoteNotifications(String deviceToken) throws AuthenticatorException {
259+
if(this.pushDeviceTokenManager.getDeviceTokenId() == null) {
260+
this.pushDeviceTokenManager.setDeviceToken(deviceToken);
260261
} else {
261-
if(this.deviceToken.equals(newDeviceToken)) {
262+
if(this.pushDeviceTokenManager.getDeviceTokenId().equals(deviceToken)) {
262263
Logger.warn(TAG, "The SDK was already initialized with this device token: %s",
263-
newDeviceToken);
264+
deviceToken);
264265
throw new AuthenticatorException("The SDK was already initialized with the FCM device token.");
265266
} else {
266267
Logger.warn(TAG, "The SDK was initialized with a different deviceToken: %s, however a new " +
267-
"device token (%s) was received.", this.deviceToken, newDeviceToken);
268-
throw new AuthenticatorException("The SDK was initialized with a different deviceToken.");
268+
"device token (%s) was received.", this.pushDeviceTokenManager.getDeviceTokenId(), deviceToken);
269+
throw new AuthenticatorException("The SDK was initialized with a different deviceToken. " +
270+
"Use FRAClient#updateDeviceToken method to update the device token.");
269271
}
270272
}
271273
}
272274

275+
void updateDeviceToken(String newDeviceToken, FRAListener<Void> listener) {
276+
Logger.debug(TAG, "Updating FCM device token for all Push mechanisms. New token: %s", newDeviceToken);
277+
278+
// Get all push mechanisms
279+
List<PushMechanism> pushMechanismList = getAllPushMechanisms();
280+
281+
// If no push mechanisms found, return success
282+
if (pushMechanismList.isEmpty()) {
283+
Logger.debug(TAG, "No push mechanisms found. Skipping device token update.");
284+
listener.onException(new PushMechanismException("Failed to retrieve PushMechanism objects."));
285+
return;
286+
}
287+
288+
// Update device token for each push mechanism
289+
final int totalMechanisms = pushMechanismList.size();
290+
final AtomicInteger completedMechanisms = new AtomicInteger(0);
291+
final List<PushMechanism> failedMechanisms = new ArrayList<>();
292+
for (PushMechanism pushMechanism : pushMechanismList) {
293+
pushDeviceTokenManager.updateDeviceToken(newDeviceToken, pushMechanism, new FRAListener<Void>() {
294+
@Override
295+
public void onSuccess(Void result) {
296+
Logger.debug(TAG, "FCM device token for mechanism %s updated successfully.", pushMechanism.getMechanismUID());
297+
if (completedMechanisms.incrementAndGet() == totalMechanisms) {
298+
if (failedMechanisms.isEmpty()) {
299+
// All mechanisms have completed successfully
300+
listener.onSuccess(null);
301+
} else {
302+
// If any mechanism failed, we report the list of failed mechanisms.
303+
listener.onException(new MechanismUpdatePushTokenException(
304+
"Error updating FCM device token for some mechanisms.", failedMechanisms));
305+
}
306+
}
307+
}
308+
309+
@Override
310+
public void onException(Exception e) {
311+
Logger.warn(TAG, "Error updating FCM device token for mechanism %s.", pushMechanism.getMechanismUID(), e);
312+
failedMechanisms.add(pushMechanism);
313+
if (completedMechanisms.incrementAndGet() == totalMechanisms) {
314+
// All mechanisms have completed, but some failed
315+
listener.onException(new MechanismUpdatePushTokenException(
316+
"Error updating FCM device token for some mechanisms.", failedMechanisms));
317+
}
318+
}
319+
});
320+
}
321+
}
322+
323+
void updateDeviceTokenForMechanism(String newDeviceToken, PushMechanism pushMechanism, FRAListener<Void> listener) {
324+
Logger.debug(TAG, "Updating FCM device token for mechanism %s. New token: %s", pushMechanism.getMechanismUID(), newDeviceToken);
325+
pushDeviceTokenManager.updateDeviceToken(newDeviceToken, pushMechanism, listener);
326+
}
327+
328+
PushDeviceToken getPushDeviceToken() {
329+
Logger.debug(TAG, "Retrieving FCM device token from StorageClient.");
330+
return pushDeviceTokenManager.getPushDeviceToken();
331+
}
332+
273333
PushNotification handleMessage(RemoteMessage message)
274334
throws InvalidNotificationException {
275335
Logger.debug(TAG, "Processing FCM remote message.");
@@ -407,5 +467,20 @@ void setNotificationFactory(NotificationFactory notificationFactory) {
407467
this.notificationFactory = notificationFactory;
408468
}
409469

470+
@VisibleForTesting
471+
List<PushMechanism> getAllPushMechanisms() {
472+
List<PushMechanism> pushMechanismList = new ArrayList<>();
473+
List<Account> accountList = getAllAccounts();
474+
for (Account account : accountList) {
475+
List<Mechanism> mechanismList = account.getMechanisms();
476+
for (Mechanism mechanism : mechanismList) {
477+
if(mechanism.getType().equals(Mechanism.PUSH)) {
478+
pushMechanismList.add((PushMechanism) mechanism);
479+
}
480+
}
481+
}
482+
return pushMechanismList;
483+
}
484+
410485
}
411486

forgerock-authenticator/src/main/java/org/forgerock/android/auth/DefaultStorageClient.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020 - 2025 Ping Identity. All rights reserved.
2+
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -23,18 +23,22 @@
2323
*/
2424
class DefaultStorageClient implements StorageClient {
2525

26+
private static final String DEVICE_TOKEN_ID = "deviceToken";
27+
2628
//Alias to store keys
2729
private static final String ORG_FORGEROCK_SHARED_PREFERENCES_KEYS = "org.forgerock.android.authenticator.KEYS";
2830

2931
//Settings to store the data
3032
private static final String ORG_FORGEROCK_SHARED_PREFERENCES_DATA_ACCOUNT = "org.forgerock.android.authenticator.DATA.ACCOUNT";
3133
private static final String ORG_FORGEROCK_SHARED_PREFERENCES_DATA_MECHANISM = "org.forgerock.android.authenticator.DATA.MECHANISM";
3234
private static final String ORG_FORGEROCK_SHARED_PREFERENCES_DATA_NOTIFICATIONS = "org.forgerock.android.authenticator.DATA.NOTIFICATIONS";
35+
private static final String ORG_FORGEROCK_SHARED_PREFERENCES_DATA_DEVICE_TOKEN = "org.forgerock.android.authenticator.DATA.DEVICE_TOKEN";
3336

3437
//The SharedPreferences to store the data
3538
private SharedPreferences accountData;
3639
private SharedPreferences mechanismData;
3740
private SharedPreferences notificationData;
41+
private SharedPreferences deviceTokenData;
3842

3943
private static final String TAG = DefaultStorageClient.class.getSimpleName();
4044

@@ -45,6 +49,8 @@ public DefaultStorageClient(Context context) {
4549
ORG_FORGEROCK_SHARED_PREFERENCES_DATA_MECHANISM, ORG_FORGEROCK_SHARED_PREFERENCES_KEYS);
4650
this.notificationData = new SecuredSharedPreferences(context,
4751
ORG_FORGEROCK_SHARED_PREFERENCES_DATA_NOTIFICATIONS, ORG_FORGEROCK_SHARED_PREFERENCES_KEYS);
52+
this.deviceTokenData = new SecuredSharedPreferences(context,
53+
ORG_FORGEROCK_SHARED_PREFERENCES_DATA_DEVICE_TOKEN, ORG_FORGEROCK_SHARED_PREFERENCES_KEYS);
4854
}
4955

5056
@Override
@@ -220,11 +226,26 @@ public PushNotification getNotificationByMessageId(String messageId) {
220226
return null;
221227
}
222228

229+
@Override
230+
public boolean setPushDeviceToken(PushDeviceToken pushDeviceToken) {
231+
String pushDeviceTokenJson = pushDeviceToken.serialize();
232+
return deviceTokenData.edit()
233+
.putString(DEVICE_TOKEN_ID, pushDeviceTokenJson)
234+
.commit();
235+
}
236+
237+
@Override
238+
public PushDeviceToken getPushDeviceToken() {
239+
String json = deviceTokenData.getString(DEVICE_TOKEN_ID, null);
240+
return PushDeviceToken.deserialize(json);
241+
}
242+
223243
@Override
224244
public boolean isEmpty() {
225245
return accountData.getAll().isEmpty() &&
226246
mechanismData.getAll().isEmpty() &&
227-
notificationData.getAll().isEmpty();
247+
notificationData.getAll().isEmpty() &&
248+
deviceTokenData.getAll().isEmpty();
228249
}
229250

230251
/**
@@ -241,6 +262,9 @@ public void removeAll() {
241262
notificationData.edit()
242263
.clear()
243264
.commit();
265+
deviceTokenData.edit()
266+
.clear()
267+
.commit();
244268
}
245269

246270
@VisibleForTesting
@@ -257,5 +281,9 @@ void setMechanismData(SharedPreferences sharedPreferences) {
257281
void setNotificationData(SharedPreferences sharedPreferences) {
258282
this.notificationData = sharedPreferences;
259283
}
260-
284+
285+
@VisibleForTesting
286+
void setDeviceTokenData(SharedPreferences sharedPreferences) {
287+
this.deviceTokenData = sharedPreferences;
288+
}
261289
}

forgerock-authenticator/src/main/java/org/forgerock/android/auth/FRAClient.java

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020 - 2025 Ping Identity. All rights reserved.
2+
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -129,7 +129,7 @@ public FRAClient start() throws AuthenticatorException {
129129
}
130130

131131
if (fcmToken == null) {
132-
Logger.warn(TAG, "A FCM token must be provided to handle Push Registrations. The method" +
132+
Logger.info(TAG, "A FCM token must be provided to handle Push Registrations. The method" +
133133
" FRAClient#registerForRemoteNotifications can also be used to register the device token.");
134134
}
135135

@@ -329,17 +329,50 @@ public PushNotification handleMessage(@NonNull String messageId, @NonNull String
329329
/**
330330
* This method allows to register the FCM device token to handle Push mechanisms after the
331331
* SDK initialization.
332-
* Note: This method cannot be used to handle FCM device token updates received on
333-
* {@link FirebaseMessagingService#onNewToken}. Currently, AM does not accept deviceToken updates
334-
* from the SDK. If any {@link PushMechanism} was registered with the previous token, this
335-
* mechanism needs to be removed and registered again using this new deviceToken.
332+
* Note: This method cannot be used to handle FCM device token updates. Instead, use the
333+
* method {@link #updateDeviceToken}
336334
* @param deviceToken the FCM device token
337335
* @throws AuthenticatorException if the SDK was already initialized with a device token
338336
*/
339337
public void registerForRemoteNotifications(@NonNull String deviceToken) throws AuthenticatorException {
340338
this.authenticatorManager.registerForRemoteNotifications(deviceToken);
341339
}
342340

341+
/**
342+
* This method allows to update the FCM device token for all registered Push mechanisms.
343+
* Use this method to handle device token updates received on {@link FirebaseMessagingService#onNewToken}.
344+
* Overall Success/Failure Logic:
345+
* If all mechanisms succeed, listener.onSuccess() is called.
346+
* If any mechanism fails, listener.onException() is called. The exception contains the list of
347+
* all failed mechanisms.
348+
* If there are no Push mechanisms found, listener.onSuccess() is called.
349+
* @param deviceToken the new FCM device token
350+
* @param listener Callback for receiving the result of device token update
351+
*/
352+
public void updateDeviceToken(@NonNull String deviceToken, @NonNull FRAListener<Void> listener) {
353+
this.authenticatorManager.updateDeviceToken(deviceToken, listener);
354+
}
355+
356+
/**
357+
* This method allows to update the FCM device token for a specific Push mechanism.
358+
* @param deviceToken the new FCM device token
359+
* @param pushMechanism the PushMechanism object
360+
* @param listener Callback for receiving the result of device token update
361+
*/
362+
public void updateDeviceTokenForMechanism(@NonNull String deviceToken, @NonNull PushMechanism pushMechanism,
363+
@NonNull FRAListener<Void> listener) {
364+
this.authenticatorManager.updateDeviceTokenForMechanism(deviceToken, pushMechanism, listener);
365+
}
366+
367+
/**
368+
* Get the PushDeviceToken object containing the FCM device token and its issued date time.
369+
* If no PushDeviceToken was found in the storage, returns {@code null}.
370+
* @return PushDeviceToken The PushDeviceToken object
371+
*/
372+
public PushDeviceToken getPushDeviceToken() {
373+
return this.authenticatorManager.getPushDeviceToken();
374+
}
375+
343376
/** No Public methods **/
344377

345378
@VisibleForTesting

forgerock-authenticator/src/main/java/org/forgerock/android/auth/HOTPMechanism.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020 - 2023 ForgeRock. All rights reserved.
2+
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -23,10 +23,11 @@ public class HOTPMechanism extends OathMechanism {
2323
/** Counter as in Int for number of OTP credentials generated */
2424
protected long counter;
2525

26-
private HOTPMechanism(String mechanismUID, String issuer, String accountName, String type, TokenType oathType,
27-
String algorithm, String secret, int digits, long counter, Calendar timeAdded) {
28-
super(mechanismUID, issuer, accountName, type, oathType, algorithm,
29-
secret, digits, timeAdded);
26+
private HOTPMechanism(String mechanismUID, String issuer, String accountName, String type,
27+
TokenType oathType, String algorithm, String secret, String uid,
28+
String resourceId, int digits, long counter, Calendar timeAdded) {
29+
super(mechanismUID, issuer, accountName, type, oathType, algorithm, secret, uid,
30+
resourceId, digits, timeAdded);
3031
this.counter = counter;
3132
}
3233

@@ -56,6 +57,8 @@ public String toJson() {
5657
jsonObject.put("accountName", getAccountName());
5758
jsonObject.put("mechanismUID", getMechanismUID());
5859
jsonObject.put("secret", getSecret());
60+
jsonObject.put("uid", getUid());
61+
jsonObject.put("resourceId", getResourceId());
5962
jsonObject.put("type", getType());
6063
jsonObject.put("oathType", getOathType());
6164
jsonObject.put("algorithm", getAlgorithm());
@@ -80,7 +83,7 @@ String serialize() {
8083
* if {@code jsonString} is empty or not able to parse it.
8184
*/
8285
public static HOTPMechanism deserialize(String jsonString) {
83-
if (jsonString == null || jsonString.length() == 0) {
86+
if (jsonString == null || jsonString.isEmpty()) {
8487
return null;
8588
}
8689
try {
@@ -90,6 +93,8 @@ public static HOTPMechanism deserialize(String jsonString) {
9093
.setAccountName(jsonObject.getString("accountName"))
9194
.setMechanismUID(jsonObject.getString("mechanismUID"))
9295
.setSecret(jsonObject.getString("secret"))
96+
.setUid(jsonObject.has("uid") ? jsonObject.getString("uid") : null)
97+
.setResourceId(jsonObject.has("resourceId") ? jsonObject.getString("resourceId") : null)
9398
.setAlgorithm(jsonObject.getString("algorithm"))
9499
.setDigits(jsonObject.getInt("digits"))
95100
.setCounter(jsonObject.getLong("counter"))
@@ -137,8 +142,8 @@ public HOTPBuilder setCounter(long counter) {
137142
*/
138143
@Override
139144
HOTPMechanism buildOath() {
140-
return new HOTPMechanism(mechanismUID, issuer, accountName, Mechanism.OATH, TokenType.HOTP, algorithm,
141-
secret, digits, counter, timeAdded);
145+
return new HOTPMechanism(mechanismUID, issuer, accountName, Mechanism.OATH,
146+
TokenType.HOTP, algorithm, secret, uid, resourceId, digits, counter, timeAdded);
142147
}
143148

144149
}

0 commit comments

Comments
 (0)