onComplete, boolean success) {
+ if (onComplete != null) {
+ try {
+ onComplete.accept(success);
+ } catch (Exception e) {
+ Log.e(TAG, "Error in completion callback", e);
+ }
+ }
+ }
+
private String getJobIdByApiName(String apiName) {
try {
SyncJobDef job = syncJobDefRepository.getSyncJobDefByApiName(apiName);
diff --git a/android/app/src/main/java/io/mosip/registration_client/utils/BatchJob.java b/android/app/src/main/java/io/mosip/registration_client/utils/BatchJob.java
index d2e4eec08..9352c0f31 100644
--- a/android/app/src/main/java/io/mosip/registration_client/utils/BatchJob.java
+++ b/android/app/src/main/java/io/mosip/registration_client/utils/BatchJob.java
@@ -10,6 +10,7 @@
import java.util.Objects;
import javax.inject.Inject;
+import javax.inject.Singleton;
import io.mosip.registration.clientmanager.constant.AuditEvent;
import io.mosip.registration.clientmanager.constant.ClientManagerConstant;
@@ -26,9 +27,9 @@
import io.mosip.registration.clientmanager.spi.AuditManagerService;
import io.mosip.registration.clientmanager.spi.LocalConfigService;
import io.mosip.registration.clientmanager.spi.PacketService;
-import io.mosip.registration_client.MainActivity;
import io.mosip.registration_client.R;
+@Singleton
public class BatchJob {
PacketService packetService;
@@ -50,8 +51,8 @@ public BatchJob(PacketService packetService, AuditManagerService auditManagerSer
this.localConfigService = localConfigService;
}
- public void setCallbackActivity(MainActivity mainActivity) {
- this.activity = mainActivity;
+ public void setCallbackActivity(Activity callbackActivity) {
+ this.activity = callbackActivity;
}
public boolean getInProgressStatus() {
diff --git a/android/app/src/main/java/io/mosip/registration_client/utils/CustomToast.java b/android/app/src/main/java/io/mosip/registration_client/utils/CustomToast.java
index be10ccf60..e8295af28 100644
--- a/android/app/src/main/java/io/mosip/registration_client/utils/CustomToast.java
+++ b/android/app/src/main/java/io/mosip/registration_client/utils/CustomToast.java
@@ -17,35 +17,51 @@ public class CustomToast{
public CustomToast(Activity activity){
this.activity = activity;
- final LayoutInflater inflater = LayoutInflater.from(activity);
- layout = inflater.inflate(R.layout.toast_layout, null);
- // Show the toast
- toast = new Toast(activity);
- toast.setView(layout);
- toast.setDuration(Toast.LENGTH_LONG);
+ if (!isActivityAvailable()) return;
+ activity.runOnUiThread(() -> {
+ final LayoutInflater inflater = LayoutInflater.from(activity);
+ layout = inflater.inflate(R.layout.toast_layout, null);
+ toast = new Toast(activity);
+ toast.setView(layout);
+ toast.setDuration(Toast.LENGTH_LONG);
+ });
}
public void showToast(){
- toast.show();
+ if (!isActivityAvailable()) return;
+ activity.runOnUiThread(() -> {
+ if(toast != null) toast.show();
+ });
}
public void setText(String text){
- if(layout!=null){
- EditText parent = layout.findViewById(R.id.toast_message);
- parent.setText(text);
- }
+ if (!isActivityAvailable()) return;
+ activity.runOnUiThread(() -> {
+ if(layout != null){
+ EditText parent = layout.findViewById(R.id.toast_message);
+ parent.setText(text);
+ }
+ });
}
public void setIcon(int icon){
- if(layout!=null){
- ImageView imageView = layout.findViewById(R.id.toast_icon);
- imageView.setImageResource(icon);
- }
+ if (!isActivityAvailable()) return;
+ activity.runOnUiThread(() -> {
+ if(layout != null){
+ ImageView imageView = layout.findViewById(R.id.toast_icon);
+ imageView.setImageResource(icon);
+ }
+ });
}
public void hideToast(){
- if(toast!=null){
- toast.cancel();
- }
+ if (!isActivityAvailable()) return;
+ activity.runOnUiThread(() -> {
+ if(toast != null) toast.cancel();
+ });
+ }
+
+ private boolean isActivityAvailable() {
+ return activity != null && !activity.isFinishing() && !activity.isDestroyed();
}
}
diff --git a/android/app/src/main/java/io/mosip/registration_client/utils/SyncScheduler.java b/android/app/src/main/java/io/mosip/registration_client/utils/SyncScheduler.java
new file mode 100644
index 000000000..493bddabe
--- /dev/null
+++ b/android/app/src/main/java/io/mosip/registration_client/utils/SyncScheduler.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) Modular Open Source Identity Platform
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+*/
+
+package io.mosip.registration_client.utils;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.work.Constraints;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import io.mosip.registration.clientmanager.constant.RegistrationConstants;
+import io.mosip.registration.clientmanager.dto.CenterMachineDto;
+import io.mosip.registration.clientmanager.entity.SyncJobDef;
+import io.mosip.registration.clientmanager.repository.GlobalParamRepository;
+import io.mosip.registration.clientmanager.repository.SyncJobDefRepository;
+import io.mosip.registration.clientmanager.spi.MasterDataService;
+import io.mosip.registration_client.SyncWorker;
+
+/**
+ * Central place for scheduling and cancelling all background sync jobs.
+ *
+ * Responsibilities:
+ * - Read cron expressions from DB / local config (via {@link BatchJob})
+ * - Translate cron → next execution time → delay
+ * - Schedule one-shot WorkManager jobs for each sync API
+ * - Re-schedule jobs after every run (called from {@link io.mosip.registration_client.SyncWorker})
+ * - Cancel individual jobs or all jobs (used from logout / stop-sync flows)
+ *
+ * Important: this class is process-safe and only depends on application {@link Context}.
+ */
+@Singleton
+public class SyncScheduler {
+
+ private static final String TAG = "SyncScheduler";
+ private static final long MIN_DELAY_MS = 60_000;
+
+ private final SyncJobDefRepository syncJobDefRepository;
+ private final GlobalParamRepository globalParamRepository;
+ private final MasterDataService masterDataService;
+ private final BatchJob batchJob;
+
+ @Inject
+ public SyncScheduler(SyncJobDefRepository syncJobDefRepository,
+ GlobalParamRepository globalParamRepository,
+ MasterDataService masterDataService,
+ BatchJob batchJob) {
+ this.syncJobDefRepository = syncJobDefRepository;
+ this.globalParamRepository = globalParamRepository;
+ this.masterDataService = masterDataService;
+ this.batchJob = batchJob;
+ }
+
+ /**
+ * Schedule (or reschedule) a single sync job for the given API name.
+ *
+ * We:
+ * - Ask {@link BatchJob#getIntervalMillis(String)} for the next cron-based execution time
+ * - Convert that absolute timestamp into an initial delay
+ * - Enqueue a unique {@link androidx.work.OneTimeWorkRequest} for {@link SyncWorker}
+ * with name "sync_{apiName}" so there is only one pending job per API.
+ */
+ public void scheduleJob(Context context, String jobApiName) {
+ Log.d(TAG, "Scheduling job: " + jobApiName);
+ try {
+ // Absolute time (epoch millis) for the next run as per cron
+ long nextExecutionTime = batchJob.getIntervalMillis(jobApiName);
+ long delay = nextExecutionTime - System.currentTimeMillis();
+
+ if (delay < MIN_DELAY_MS) {
+ Log.w(TAG, jobApiName + " - Delay too small (" + delay + "ms), using minimum");
+ delay = MIN_DELAY_MS;
+ }
+
+ Data inputData = new Data.Builder()
+ .putString(SyncWorker.KEY_JOB_API_NAME, jobApiName)
+ .build();
+
+ // Require network connectivity for all server-side sync operations.
+ Constraints constraints = new Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build();
+
+ OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SyncWorker.class)
+ .setInputData(inputData)
+ .setInitialDelay(delay, TimeUnit.MILLISECONDS)
+ .setConstraints(constraints)
+ .addTag("sync_job")
+ .addTag("sync_job_" + jobApiName)
+ .build();
+
+ // Use a unique name per API so that only one pending job per sync API exists.
+ // For routine bootstrap scheduling (e.g. app startup), KEEP preserves any
+ // already enqueued work that may be waiting on constraints like network.
+ String uniqueWorkName = "sync_" + jobApiName;
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest);
+
+ Log.d(TAG, jobApiName + " - Scheduled, next execution in " + (delay / 1000) + " seconds");
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling job: " + jobApiName, e);
+ }
+ }
+
+ public void scheduleAllActiveJobs(Context context) {
+ new Thread(() -> {
+ try {
+ CenterMachineDto dto = masterDataService.getRegistrationCenterMachineDetails();
+ if (dto == null || dto.getMachineRefId() == null) {
+ Log.w(TAG, "Machine not configured - skipping auto sync initialization");
+ return;
+ }
+
+ List activeJobs = syncJobDefRepository.getAllSyncJobDefList();
+ Set excludedJobIds = getExcludedJobIds();
+ int scheduledCount = 0;
+
+ for (SyncJobDef job : activeJobs) {
+ if (job.getId() == null) continue;
+ if (excludedJobIds.contains(job.getId())) {
+ Log.d(TAG, "Skipping excluded job: " + job.getId());
+ continue;
+ }
+ if (job.getIsActive() != null && job.getIsActive() && job.getApiName() != null) {
+ Log.d(TAG, "Scheduling job: " + job.getApiName() +
+ " (ID: " + job.getId() + ", Cron: " + job.getSyncFreq() + ")");
+ scheduleJob(context, job.getApiName());
+ scheduledCount++;
+ }
+ }
+
+ Log.d(TAG, "Scheduled " + scheduledCount + " active jobs via WorkManager");
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling all active jobs", e);
+ }
+ }).start();
+ }
+
+ public void cancelJob(Context context, String jobApiName) {
+ String uniqueWorkName = "sync_" + jobApiName;
+ WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName);
+ Log.d(TAG, "Cancelled job: " + jobApiName);
+ }
+
+ public void cancelAllJobs(Context context) {
+ WorkManager.getInstance(context).cancelAllWorkByTag("sync_job");
+ Log.d(TAG, "Cancelled all sync jobs");
+ }
+
+ private Set getExcludedJobIds() {
+ Set excluded = new HashSet<>();
+ addJobIdsFromString(excluded, globalParamRepository.getCachedStringJobsOffline());
+ addJobIdsFromString(excluded, globalParamRepository.getCachedStringJobsUntagged());
+ return excluded;
+ }
+
+ private void addJobIdsFromString(Set target, String value) {
+ if (value == null || value.trim().isEmpty()) return;
+ for (String jobId : value.split(RegistrationConstants.COMMA)) {
+ String trimmed = jobId.trim();
+ if (!trimmed.isEmpty()) {
+ target.add(trimmed);
+ }
+ }
+ }
+}
diff --git a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/NetworkModule.java b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/NetworkModule.java
index 25db6f177..5954c4d66 100644
--- a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/NetworkModule.java
+++ b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/NetworkModule.java
@@ -8,6 +8,7 @@
import dagger.Module;
import dagger.Provides;
import io.mosip.registration.clientmanager.BuildConfig;
+import io.mosip.registration.clientmanager.dao.UserTokenDao;
import io.mosip.registration.clientmanager.interceptor.RestAuthInterceptor;
import io.mosip.registration.clientmanager.repository.GlobalParamRepository;
import io.mosip.registration.clientmanager.spi.SyncRestService;
@@ -52,10 +53,10 @@ Gson provideGson() {
@Provides
@Singleton
- OkHttpClient provideOkhttpClient(Cache cache, GlobalParamRepository globalParamRepository) {
+ OkHttpClient provideOkhttpClient(Cache cache, GlobalParamRepository globalParamRepository, UserTokenDao userTokenDao) {
OkHttpClient.Builder client = new OkHttpClient.Builder();
client.cache(cache);
- client.addInterceptor(new RestAuthInterceptor(appContext));
+ client.addInterceptor(new RestAuthInterceptor(appContext, userTokenDao));
long cachedReadTimeout = globalParamRepository.getCachedReadTimeout();
long cachedWriteTimeout = globalParamRepository.getCachedWriteTimeout();
diff --git a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/SessionManager.java b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/SessionManager.java
index 7f9c5a0d3..2a64ffc92 100644
--- a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/SessionManager.java
+++ b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/config/SessionManager.java
@@ -31,6 +31,9 @@ public class SessionManager {
private static final String USERNAME = "name";
public static final String PREFERRED_USERNAME = "preferred_username";
private static final String EMAIL = "email";
+ // Leeway (in seconds) for JWT expiry checks to tolerate clock skew between
+ // client and server. This should be small and consistent across the app.
+ private static final int TOKEN_EXPIRY_CLOCK_SKEW_LEEWAY_SECONDS = 90;
SharedPreferences sharedPreferences;
@@ -51,19 +54,35 @@ public static SessionManager getSessionManager(Context context) {
}
public List saveAuthToken(@NonNull String token) throws Exception {
- final JWT jwt = new JWT(token);
- if(jwt.isExpired(15))
- throw new Exception("Expired token found : " + jwt.getExpiresAt());
+ List roles = validateAndExtractRoles(token);
- Map realmAccess = jwt.getClaim(REALM_ACCESS).asObject(Map.class);
- List roles = (List)realmAccess.get("roles");
+ SharedPreferences.Editor editor = this.context.getSharedPreferences(this.context.getString(R.string.app_name),
+ Context.MODE_PRIVATE).edit();
+ editor.putString(USER_TOKEN, token);
+ editor.putString(USER_NAME, new JWT(token).getClaim(USERNAME).asString());
+ editor.putString(PREFERRED_USERNAME, new JWT(token).getClaim(PREFERRED_USERNAME).asString());
+ editor.putString(USER_EMAIL, new JWT(token).getClaim(EMAIL).asString());
+ editor.putBoolean(IS_SUPERVISOR, roles.contains("REGISTRATION_SUPERVISOR"));
+ editor.putBoolean(IS_DEFAULT, roles.contains("Default"));
+ editor.putBoolean(IS_OFFICER, roles.contains("REGISTRATION_OFFICER"));
+ editor.putBoolean(IS_OPERATOR, roles.contains("REGISTRATION_OPERATOR"));
+ editor.apply();
+ return roles;
+ }
- if(roles.isEmpty())
- throw new Exception("Unauthorized access, No roles");
+ /**
+ * Synchronous variant of {@link #saveAuthToken(String)} that uses commit()
+ * instead of apply() so callers can rely on the token being visible to
+ * other threads immediately after this call returns.
+ */
+ public List saveAuthTokenSync(@NonNull String token) throws Exception {
+ List roles = validateAndExtractRoles(token);
- if(!roles.contains("REGISTRATION_SUPERVISOR") && !roles.contains("REGISTRATION_OFFICER") && !roles.contains("REGISTRATION_OPERATOR"))
- throw new Exception("Unauthorized access, Required roles not found");
+ final JWT jwt = new JWT(token);
+ if(jwt.isExpired(TOKEN_EXPIRY_CLOCK_SKEW_LEEWAY_SECONDS))
+ throw new Exception("Expired token found : " + jwt.getExpiresAt());
+ Map realmAccess = jwt.getClaim(REALM_ACCESS).asObject(Map.class);
SharedPreferences.Editor editor = this.context.getSharedPreferences(this.context.getString(R.string.app_name),
Context.MODE_PRIVATE).edit();
editor.putString(USER_TOKEN, token);
@@ -74,7 +93,34 @@ public List saveAuthToken(@NonNull String token) throws Exception {
editor.putBoolean(IS_DEFAULT, roles.contains("Default"));
editor.putBoolean(IS_OFFICER, roles.contains("REGISTRATION_OFFICER"));
editor.putBoolean(IS_OPERATOR, roles.contains("REGISTRATION_OPERATOR"));
- editor.apply();
+ if (!editor.commit()) {
+ throw new Exception("Failed to persist auth token");
+ }
+ return roles;
+ }
+
+ /**
+ * Common validation and role extraction logic used by both async and sync
+ * token save methods.
+ */
+ private List validateAndExtractRoles(@NonNull String token) throws Exception {
+ final JWT jwt = new JWT(token);
+ if(jwt.isExpired(TOKEN_EXPIRY_CLOCK_SKEW_LEEWAY_SECONDS))
+ throw new Exception("Expired token found : " + jwt.getExpiresAt());
+
+ Map realmAccess = jwt.getClaim(REALM_ACCESS).asObject(Map.class);
+ Object rolesClaim = realmAccess != null ? realmAccess.get("roles") : null;
+ if (!(rolesClaim instanceof List)) {
+ throw new Exception("Unauthorized access, No roles");
+ }
+ List roles = (List) rolesClaim;
+
+ if(roles.isEmpty())
+ throw new Exception("Unauthorized access, No roles");
+
+ if(!roles.contains("REGISTRATION_SUPERVISOR") && !roles.contains("REGISTRATION_OFFICER") && !roles.contains("REGISTRATION_OPERATOR"))
+ throw new Exception("Unauthorized access, Required roles not found");
+
return roles;
}
diff --git a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/interceptor/RestAuthInterceptor.java b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/interceptor/RestAuthInterceptor.java
index c8bc9b8ec..3f0031a52 100644
--- a/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/interceptor/RestAuthInterceptor.java
+++ b/android/clientmanager/src/main/java/io/mosip/registration/clientmanager/interceptor/RestAuthInterceptor.java
@@ -2,28 +2,109 @@
import android.content.Context;
import io.mosip.registration.clientmanager.config.SessionManager;
+import io.mosip.registration.clientmanager.dao.UserTokenDao;
+import io.mosip.registration.clientmanager.entity.UserToken;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
+import com.auth0.android.jwt.JWT;
+
import java.io.IOException;
+import java.util.Date;
public class RestAuthInterceptor implements Interceptor {
private static final String COOKIE = "Cookie";
private static final String TOKEN_TEMPLATE = "Authorization=%s";
+ // Leeway in seconds for JWT expiry checks (clock skew only). Keep this small and
+ // consistent with SessionManager.
+ private static final int TOKEN_EXPIRY_CLOCK_SKEW_LEEWAY_SECONDS = 90;
+ // Extra safety buffer (in seconds) applied at decision points so we avoid sending
+ // requests with tokens that are about to expire.
+ private static final int TOKEN_EXPIRY_SAFETY_BUFFER_SECONDS = 120;
+ private final Object restoreLock = new Object();
private SessionManager sessionManager;
+ private final UserTokenDao userTokenDao;
+ private final Context appContext;
- public RestAuthInterceptor(Context context) {
- this.sessionManager = SessionManager.getSessionManager(context);
+ public RestAuthInterceptor(Context context, UserTokenDao userTokenDao) {
+ this.appContext = context.getApplicationContext();
+ this.sessionManager = SessionManager.getSessionManager(this.appContext);
+ this.userTokenDao = userTokenDao;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request.Builder requestBuilder = chain.request().newBuilder();
- if(this.sessionManager.fetchAuthToken() != null) {
- requestBuilder.addHeader(COOKIE, String.format(TOKEN_TEMPLATE, this.sessionManager.fetchAuthToken()));
+ String token = this.sessionManager.fetchAuthToken();
+ token = ensureValidSessionToken(token);
+ if (token != null) {
+ requestBuilder.addHeader(COOKIE, String.format(TOKEN_TEMPLATE, token));
}
return chain.proceed(requestBuilder.build());
}
+
+ private String ensureValidSessionToken(String token) {
+ // If there's no token (or it is expired), try to restore it from DB so background jobs don't fail
+ // just because SharedPreferences were cleared.
+ if (isTokenValid(token)) {
+ return token;
+ }
+
+ try {
+ synchronized (restoreLock) {
+ // Another thread may have already restored a valid token.
+ String currentToken = this.sessionManager.fetchAuthToken();
+ if (isTokenValid(currentToken)) {
+ return currentToken;
+ }
+
+ String userId = appContext
+ .getSharedPreferences(appContext.getString(io.mosip.registration.clientmanager.R.string.app_name), Context.MODE_PRIVATE)
+ .getString(SessionManager.PREFERRED_USERNAME, null);
+ if (userId == null || userId.isEmpty()) {
+ return null;
+ }
+
+ UserToken userToken = userTokenDao.findByUsername(userId);
+ if (userToken == null) {
+ return null;
+ }
+
+ String dbToken = userToken.getToken();
+ if (!isTokenValid(dbToken)) {
+ return null;
+ }
+
+ // Use the most recent token from the local UserToken table directly
+ // without writing it back to SessionManager here.
+ return dbToken;
+ }
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private boolean isTokenValid(String token) {
+ if (token == null || token.isEmpty()) {
+ return false;
+ }
+ try {
+ JWT jwt = new JWT(token);
+ if (jwt.isExpired(TOKEN_EXPIRY_CLOCK_SKEW_LEEWAY_SECONDS)) {
+ return false;
+ }
+ Date expiresAt = jwt.getExpiresAt();
+ if (expiresAt != null) {
+ long msRemaining = expiresAt.getTime() - System.currentTimeMillis();
+ if (msRemaining <= TOKEN_EXPIRY_SAFETY_BUFFER_SECONDS * 1000L) {
+ return false;
+ }
+ }
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
}
diff --git a/android/clientmanager/src/test/java/io/mosip/registration/clientmanager/service/RestAuthInterceptorTest.java b/android/clientmanager/src/test/java/io/mosip/registration/clientmanager/service/RestAuthInterceptorTest.java
index eece8930f..f3ab6048a 100644
--- a/android/clientmanager/src/test/java/io/mosip/registration/clientmanager/service/RestAuthInterceptorTest.java
+++ b/android/clientmanager/src/test/java/io/mosip/registration/clientmanager/service/RestAuthInterceptorTest.java
@@ -1,7 +1,9 @@
package io.mosip.registration.clientmanager.service;
import android.content.Context;
+
import io.mosip.registration.clientmanager.config.SessionManager;
+import io.mosip.registration.clientmanager.dao.UserTokenDao;
import io.mosip.registration.clientmanager.interceptor.RestAuthInterceptor;
import okhttp3.Interceptor;
import okhttp3.Request;
@@ -9,11 +11,15 @@
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.mockito.*;
+import org.mockito.Mock;
+import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
-import static org.junit.Assert.*;
-import static org.mockito.Mockito.*;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class RestAuthInterceptorTest {
@@ -36,6 +42,9 @@ public class RestAuthInterceptorTest {
@Mock
Response mockResponse;
+ @Mock
+ UserTokenDao mockUserTokenDao;
+
@Before
public void setUp() {
Mockito.mockStatic(SessionManager.class)
@@ -52,7 +61,7 @@ public void testIntercept_withAuthToken_addsHeader() throws Exception {
when(mockRequestBuilder.build()).thenReturn(mockRequest);
when(mockChain.proceed(mockRequest)).thenReturn(mockResponse);
- RestAuthInterceptor interceptor = new RestAuthInterceptor(mockContext);
+ RestAuthInterceptor interceptor = new RestAuthInterceptor(mockContext, mockUserTokenDao);
Response response = interceptor.intercept(mockChain);
verify(mockRequestBuilder).addHeader(eq("Cookie"), eq("Authorization=token123"));
diff --git a/android/packetmanager/src/main/java/io/mosip/registration/packetmanager/util/StorageUtils.java b/android/packetmanager/src/main/java/io/mosip/registration/packetmanager/util/StorageUtils.java
index 7134caba0..589007bba 100644
--- a/android/packetmanager/src/main/java/io/mosip/registration/packetmanager/util/StorageUtils.java
+++ b/android/packetmanager/src/main/java/io/mosip/registration/packetmanager/util/StorageUtils.java
@@ -1,6 +1,7 @@
package io.mosip.registration.packetmanager.util;
import android.content.Context;
+import android.os.Build;
import android.os.Environment;
import android.util.Log;
@@ -15,20 +16,25 @@ public static File getPacketStorageDir(Context context) {
location = "packets";
}
- // 1. Try SD card Documents folder
- File baseDir = getSDCardDir(context, location);
- if (baseDir != null && ensureDirWritable(baseDir)) {
- return baseDir;
- }
+ // Use shared "Documents" locations only when the app has
+ // MANAGE_EXTERNAL_STORAGE access on Android 11+; otherwise, fall
+ // back to app-specific storage to avoid unauthorized shared access.
+ if (hasManageExternalStorageAccess()) {
+ // 1. Try SD card Documents folder
+ File baseDir = getSDCardDir(context, location);
+ if (baseDir != null && ensureDirWritable(baseDir)) {
+ return baseDir;
+ }
- // 2. Try Primary Shared Documents folder (Legacy/Scoped Storage might restrict this)
- baseDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), location);
- if (ensureDirWritable(baseDir)) {
- return baseDir;
+ // 2. Try Primary Shared Documents folder (Legacy/Scoped Storage might restrict this)
+ baseDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), location);
+ if (ensureDirWritable(baseDir)) {
+ return baseDir;
+ }
}
// 3. Fallback to App-private external storage
- baseDir = context.getExternalFilesDir(location);
+ File baseDir = context.getExternalFilesDir(location);
if (baseDir != null && ensureDirWritable(baseDir)) {
return baseDir;
}
@@ -39,6 +45,15 @@ public static File getPacketStorageDir(Context context) {
return baseDir;
}
+ private static boolean hasManageExternalStorageAccess() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ // Before Android 11, MANAGE_EXTERNAL_STORAGE is not required in
+ // order to access shared storage via getExternalStoragePublicDirectory.
+ return true;
+ }
+ return Environment.isExternalStorageManager();
+ }
+
private static File getSDCardDir(Context context, String location) {
File[] externalFilesDirs = context.getExternalFilesDirs(null);
if (externalFilesDirs != null) {