diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d02a5f95..e6f1a2cd9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,25 +1,27 @@ - - + + + + + + + - - - - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> @@ -56,4 +58,4 @@ - + \ No newline at end of file diff --git a/android/app/src/main/java/io/mosip/registration_client/AppComponent.java b/android/app/src/main/java/io/mosip/registration_client/AppComponent.java index 37261dafa..9e5249a05 100644 --- a/android/app/src/main/java/io/mosip/registration_client/AppComponent.java +++ b/android/app/src/main/java/io/mosip/registration_client/AppComponent.java @@ -19,6 +19,8 @@ import io.mosip.registration.clientmanager.config.AppModule; import io.mosip.registration.clientmanager.config.NetworkModule; import io.mosip.registration.clientmanager.config.RoomModule; +import io.mosip.registration_client.api_services.MasterDataSyncApi; +import io.mosip.registration_client.utils.SyncScheduler; @Singleton @Component( @@ -34,6 +36,10 @@ public interface AppComponent extends AndroidInjector { void inject(MainActivity mainActivity); + MasterDataSyncApi masterDataSyncApi(); + + SyncScheduler syncScheduler(); + @Component.Builder interface Builder{ @BindsInstance diff --git a/android/app/src/main/java/io/mosip/registration_client/HostApiModule.java b/android/app/src/main/java/io/mosip/registration_client/HostApiModule.java index 19028e364..6f2557892 100644 --- a/android/app/src/main/java/io/mosip/registration_client/HostApiModule.java +++ b/android/app/src/main/java/io/mosip/registration_client/HostApiModule.java @@ -73,6 +73,8 @@ import io.mosip.registration_client.api_services.RegistrationApi; import io.mosip.registration_client.api_services.SecureScreenApi; import io.mosip.registration_client.api_services.UserDetailsApi; +import io.mosip.registration_client.utils.BatchJob; +import io.mosip.registration_client.utils.SyncScheduler; @Module public class HostApiModule { @@ -119,9 +121,11 @@ MachineDetailsApi getMachineDetailsApi(ClientCryptoManagerService clientCryptoMa AuthenticationApi getAuthenticationApi(SyncRestService syncRestService, SyncRestUtil syncRestFactory, LoginService loginService, - AuditManagerService auditManagerService,GlobalParamRepository globalParamRepository) { + AuditManagerService auditManagerService, + GlobalParamRepository globalParamRepository, + SyncScheduler syncScheduler) { return new AuthenticationApi(appContext, syncRestService, syncRestFactory, - loginService, auditManagerService, globalParamRepository); + loginService, auditManagerService, globalParamRepository, syncScheduler); } @Provides @@ -199,7 +203,8 @@ MasterDataSyncApi getSyncResponseApi( AuditManagerService auditManagerService, MasterDataService masterDataService, PacketService packetService, - GlobalParamDao globalParamDao, FileSignatureDao fileSignatureDao,PreRegistrationDataSyncService preRegistrationDataSyncService, LocalConfigService localConfigService) { + GlobalParamDao globalParamDao, FileSignatureDao fileSignatureDao, PreRegistrationDataSyncService preRegistrationDataSyncService, LocalConfigService localConfigService, + BatchJob batchJob, SyncScheduler syncScheduler) { return new MasterDataSyncApi(clientCryptoManagerService, machineRepository, registrationCenterRepository, syncRestService, certificateManagerService, @@ -209,7 +214,8 @@ MasterDataSyncApi getSyncResponseApi( templateRepository, dynamicFieldRepository, locationRepository, blocklistedWordRepository, syncJobDefRepository, languageRepository, jobManagerService, - auditManagerService, masterDataService, packetService, globalParamDao, fileSignatureDao, preRegistrationDataSyncService, localConfigService + auditManagerService, masterDataService, packetService, globalParamDao, fileSignatureDao, preRegistrationDataSyncService, localConfigService, + batchJob, syncScheduler ); } diff --git a/android/app/src/main/java/io/mosip/registration_client/MainActivity.java b/android/app/src/main/java/io/mosip/registration_client/MainActivity.java index 27a9833b5..6bce16f4c 100644 --- a/android/app/src/main/java/io/mosip/registration_client/MainActivity.java +++ b/android/app/src/main/java/io/mosip/registration_client/MainActivity.java @@ -8,65 +8,22 @@ package io.mosip.registration_client; import android.app.Activity; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.Build; import android.os.Bundle; -import android.os.SystemClock; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.fasterxml.jackson.databind.ObjectWriter; - -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - import javax.inject.Inject; import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.plugins.GeneratedPluginRegistrant; -import io.mosip.registration.clientmanager.config.AppModule; -import io.mosip.registration.clientmanager.config.NetworkModule; -import io.mosip.registration.clientmanager.config.RoomModule; import io.mosip.registration.clientmanager.constant.AuditEvent; import io.mosip.registration.clientmanager.constant.Components; -import io.mosip.registration.clientmanager.constant.PacketClientStatus; -import io.mosip.registration.clientmanager.constant.PacketTaskStatus; -import io.mosip.registration.clientmanager.constant.RegistrationConstants; -import io.mosip.registration.clientmanager.dao.GlobalParamDao; -import io.mosip.registration.clientmanager.dto.CenterMachineDto; -import io.mosip.registration.clientmanager.entity.GlobalParam; -import io.mosip.registration.clientmanager.entity.Registration; -import io.mosip.registration.clientmanager.entity.SyncJobDef; -import io.mosip.registration.clientmanager.repository.GlobalParamRepository; -import io.mosip.registration.clientmanager.repository.IdentitySchemaRepository; -import io.mosip.registration.clientmanager.repository.RegistrationCenterRepository; -import io.mosip.registration.clientmanager.repository.SyncJobDefRepository; -import io.mosip.registration.clientmanager.repository.UserDetailRepository; -import io.mosip.registration.clientmanager.service.LoginService; -import io.mosip.registration.clientmanager.spi.AsyncPacketTaskCallBack; import io.mosip.registration.clientmanager.spi.AuditManagerService; -import io.mosip.registration.clientmanager.spi.JobManagerService; -import io.mosip.registration.clientmanager.spi.JobTransactionService; -import io.mosip.registration.clientmanager.spi.MasterDataService; -import io.mosip.registration.clientmanager.spi.PacketService; import io.mosip.registration.clientmanager.spi.RegistrationService; -import io.mosip.registration.clientmanager.spi.SyncRestService; -import io.mosip.registration.clientmanager.util.SyncRestUtil; -import io.mosip.registration.keymanager.spi.ClientCryptoManagerService; import io.mosip.registration.transliterationmanager.service.TransliterationServiceImpl; import io.mosip.registration_client.api_services.AuditDetailsApi; @@ -106,45 +63,19 @@ import io.mosip.registration_client.model.UserPigeon; import io.mosip.registration_client.model.DocumentDataPigeon; import io.mosip.registration_client.utils.BatchJob; -import io.mosip.registration_client.utils.CustomToast; +import io.mosip.registration_client.utils.SyncScheduler; -import android.net.Uri; +import android.content.Intent; public class MainActivity extends FlutterActivity { private static final String REG_CLIENT_CHANNEL = "com.flutter.dev/io.mosip.get-package-instance"; - ObjectWriter ow; - @Inject - ClientCryptoManagerService clientCryptoManagerService; - @Inject - SyncRestUtil syncRestFactory; - @Inject - SyncRestService syncRestService; - @Inject - LoginService loginService; @Inject AuditManagerService auditManagerService; - @Inject - MasterDataService masterDataService; @Inject RegistrationService registrationService; - @Inject - PacketService packetService; - @Inject - JobTransactionService jobTransactionService; - @Inject - JobManagerService jobManagerService; - @Inject - IdentitySchemaRepository identitySchemaRepository; - @Inject - UserDetailRepository userDetailRepository; - @Inject - RegistrationCenterRepository registrationCenterRepository; - - @Inject - GlobalParamRepository globalParamRepository; @Inject MachineDetailsApi machineDetailsApi; @@ -181,14 +112,9 @@ public class MainActivity extends FlutterActivity { @Inject MasterDataSyncApi masterDataSyncApi; - @Inject - AuditDetailsApi auditDetailsApi; @Inject - SyncJobDefRepository syncJobDefRepository; - - @Inject - GlobalParamDao globalParamDao; + AuditDetailsApi auditDetailsApi; @Inject DocumentCategoryApi documentCategoryApi; @@ -205,189 +131,27 @@ public class MainActivity extends FlutterActivity { @Inject SecureScreenApi secureScreenApi; - private BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String jobApiName = intent.getStringExtra(UploadBackgroundService.EXTRA_JOB_API_NAME); - if (jobApiName == null) jobApiName = "registrationPacketUploadJob"; // Backward compatibility - - // Execute the job based on API name - masterDataSyncApi.executeJobByApiName(jobApiName, context); - - // Reschedule next execution - String finalJobApiName = jobApiName; - ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - scheduler.schedule(()-> { - createBackgroundTask(finalJobApiName); - }, 1, TimeUnit.MINUTES); - } - }; - - private BroadcastReceiver rescheduleReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String jobApiName = intent.getStringExtra(UploadBackgroundService.EXTRA_JOB_API_NAME); - if (jobApiName != null) { - Log.d(getClass().getSimpleName(), "Rescheduling job due to cron change: " + jobApiName); - createBackgroundTask(jobApiName); - } - } - }; + @Inject + SyncScheduler syncScheduler; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); -// createBackgroundTask("registrationPacketUploadJob"); - IntentFilter intentFilterUpload = new IntentFilter("SYNC_JOB_TRIGGER"); - registerReceiver(broadcastReceiver, intentFilterUpload); - - // Register receiver for rescheduling jobs when cron expression changes - IntentFilter rescheduleFilter = new IntentFilter("RESCHEDULE_JOB"); - registerReceiver(rescheduleReceiver, rescheduleFilter); - } - - private void initializeAutoSync() { - try { - CenterMachineDto dto = masterDataService.getRegistrationCenterMachineDetails(); - if (dto != null && dto.getMachineRefId() != null) { - Log.d(getClass().getSimpleName(), "Machine configured - initializing auto sync"); - scheduleAllActiveJobs(); - } else { - Log.w(getClass().getSimpleName(), "Machine not configured yet - skipping auto sync initialization"); - } - } catch (Exception e) { - Log.e(getClass().getSimpleName(), "Error initializing auto sync", e); - } - } - - // Schedule all active jobs from database - void scheduleAllActiveJobs() { - new Thread(() -> { - try { - List activeJobs = syncJobDefRepository.getAllSyncJobDefList(); - int scheduledCount = 0; - Set excludedJobIds = getExcludedJobIds(); - for (SyncJobDef job : activeJobs) { - if (job.getId() == null) { - continue; - } - if (excludedJobIds.contains(job.getId())) { - Log.d(getClass().getSimpleName(), "Skipping excluded job: " + job.getId()); - continue; - } - if (job.getIsActive() != null && job.getIsActive() && job.getApiName() != null) { - Log.d(getClass().getSimpleName(), "Scheduling job: " + job.getApiName() + - " (ID: " + job.getId() + ", Cron: " + job.getSyncFreq() + ")"); - - runOnUiThread(() -> createBackgroundTask(job.getApiName())); - scheduledCount++; - } - } - - Log.d(getClass().getSimpleName(), "Scheduled " + scheduledCount + " active jobs"); - } catch (Exception e) { - Log.e(getClass().getSimpleName(), "Error scheduling jobs", e); - } - }).start(); - } - - 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); - } - } } @Override protected void onDestroy() { super.onDestroy(); - unregisterReceiver(broadcastReceiver); - try { - unregisterReceiver(rescheduleReceiver); - } catch (Exception e) { - // Receiver might not be registered, ignore - } - } - - public void createBackgroundTask(String api){ - try { - Intent intent = new Intent(this, UploadBackgroundService.class); - intent.putExtra(UploadBackgroundService.EXTRA_JOB_API_NAME, api); // Pass job API name - - // Use unique request code per job to prevent conflicts - int requestCode = Math.abs(api.hashCode() % 10000); - - PendingIntent pendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - pendingIntent = PendingIntent.getForegroundService(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE); - } else { - pendingIntent = PendingIntent.getService(this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - if (alarmManager != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { - Intent permissionIntent = new Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); - permissionIntent.setData(Uri.fromParts("package", getPackageName(), null)); - startActivity(permissionIntent); - return; - } - - // Get next execution time from cron expression - long alarmTime = batchJob.getIntervalMillis(api); - long currentTime = System.currentTimeMillis(); - long delay = alarmTime - currentTime; - - // Ensure delay is positive (prevent immediate execution) - if (delay < 0 || delay < 60000) { - Log.w(getClass().getSimpleName(), api + " - Calculated delay is too small (" + delay + "ms), using 1 minute minimum"); - delay = 60000; // Minimum 1 minute - } - - Log.d(getClass().getSimpleName(), api + " - Request code: " + requestCode + - ", Next execution in: " + (delay / 1000) + " seconds"); - - // Cancel old alarm before scheduling new one - alarmManager.cancel(pendingIntent); - - // Schedule alarm - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } else { - alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } - - Log.d(getClass().getSimpleName(), api + " - Alarm scheduled successfully"); - } - } catch (Exception e) { - Log.e(getClass().getSimpleName(), "Error scheduling job: " + api, e); - } } public void initializeAppComponent() { - AppComponent appComponent = DaggerAppComponent.builder() - .application(getApplication()) - .networkModule(new NetworkModule(getApplication())) - .roomModule(new RoomModule(getApplication(), getApplicationInfo())) - .appModule(new AppModule(getApplication())) - .hostApiModule(new HostApiModule(getApplication())) - .build(); + RegistrationClientApp app = (RegistrationClientApp) getApplication(); + AppComponent appComponent = app.getAppComponent(); appComponent.inject(this); - initializeAutoSync(); + // Kick off WorkManager scheduling for all active sync jobs. The actual + // execution happens in {@link SyncWorker}; this call only ensures that + // cron-based jobs are enqueued once when the UI starts. + syncScheduler.scheduleAllActiveJobs(getApplicationContext()); } @Override @@ -415,7 +179,7 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { DynamicResponsePigeon.DynamicResponseApi.setup(flutterEngine.getDartExecutor().getBinaryMessenger(), dynamicDetailsApi); batchJob.setCallbackActivity(this); MasterDataSyncPigeon.SyncApi.setup(flutterEngine.getDartExecutor().getBinaryMessenger(), masterDataSyncApi); - masterDataSyncApi.setCallbackActivity(this, batchJob, flutterEngine.getDartExecutor().getBinaryMessenger()); + masterDataSyncApi.setCallbackActivity(this, flutterEngine.getDartExecutor().getBinaryMessenger()); AuditResponsePigeon.AuditResponseApi.setup(flutterEngine.getDartExecutor().getBinaryMessenger(), auditDetailsApi); GlobalConfigSettingsPigeon.GlobalConfigSettingsApi.setup(flutterEngine.getDartExecutor().getBinaryMessenger(), globalConfigSettingsApi); SecureScreenPigeon.SecureScreenApi.setup(flutterEngine.getDartExecutor().getBinaryMessenger(), secureScreenApi); diff --git a/android/app/src/main/java/io/mosip/registration_client/RegistrationClientApp.java b/android/app/src/main/java/io/mosip/registration_client/RegistrationClientApp.java new file mode 100644 index 000000000..f2cf27da8 --- /dev/null +++ b/android/app/src/main/java/io/mosip/registration_client/RegistrationClientApp.java @@ -0,0 +1,52 @@ +/* + * 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; + +import android.app.Application; + +import io.mosip.registration.clientmanager.config.AppModule; +import io.mosip.registration.clientmanager.config.NetworkModule; +import io.mosip.registration.clientmanager.config.RoomModule; + +/** + * Custom {@link Application} used as the root for dependency injection. + * + * - Builds the Dagger {@link AppComponent} once per process. + * - Exposes that component to both UI ({@link MainActivity}) and background + * work ({@link SyncWorker}) via {@link #getAppComponent()}. + * + * Using a real Application class (referenced from AndroidManifest.xml) is + * what allows WorkManager jobs to resolve the same graph even when the UI + * process is recreated solely to run background work. + */ +public class RegistrationClientApp extends Application { + + private volatile AppComponent appComponent; + + @Override + public void onCreate() { + super.onCreate(); + } + + public synchronized AppComponent getAppComponent() { + if (appComponent == null) { + appComponent = DaggerAppComponent.builder() + .application(this) + .networkModule(new NetworkModule(this)) + .roomModule(new RoomModule(this, getApplicationInfo())) + .appModule(new AppModule(this)) + .hostApiModule(new HostApiModule(this)) + .build(); + } + return appComponent; + } + + public synchronized void setAppComponent(AppComponent component) { + this.appComponent = component; + } +} diff --git a/android/app/src/main/java/io/mosip/registration_client/SyncWorker.java b/android/app/src/main/java/io/mosip/registration_client/SyncWorker.java new file mode 100644 index 000000000..ec6630f99 --- /dev/null +++ b/android/app/src/main/java/io/mosip/registration_client/SyncWorker.java @@ -0,0 +1,97 @@ +/* + * 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; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.mosip.registration_client.api_services.MasterDataSyncApi; +import io.mosip.registration_client.utils.SyncScheduler; + +/** + * WorkManager worker that performs a single execution of a sync job. + * + * Lifecycle: + * - Scheduled by {@link SyncScheduler#scheduleJob(Context, String)} with an initial delay + * derived from the job's cron expression. + * - When the WorkManager delay expires, {@link #doWork()} is called. + * - We resolve the Dagger graph from {@link RegistrationClientApp}, run the job via + * {@link MasterDataSyncApi#executeJobByApiName(String, Context, java.util.function.Consumer)}, and wait + * for completion (bounded by {@link #SYNC_TIMEOUT_MINUTES}). + * - When finished, we ask {@link SyncScheduler} to schedule the *next* run for the same job. + * + * This keeps each job run independent and lets WorkManager persist and reschedule work + * across process death and device reboot. + */ +public class SyncWorker extends Worker { + + public static final String KEY_JOB_API_NAME = "job_api_name"; + private static final String TAG = "SyncWorker"; + // WorkManager enforces a ~10 minute execution limit per Worker. Use a slightly + // lower timeout here so there is enough time left to perform cleanup and + // return Result.retry() before the Worker is forcibly stopped. + private static final long SYNC_TIMEOUT_MINUTES = 9; + + public SyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + // The API name of the job we should execute (e.g. "registrationPacketUploadJob"). + String jobApiName = getInputData().getString(KEY_JOB_API_NAME); + if (jobApiName == null || jobApiName.isEmpty()) { + Log.e(TAG, "No job API name provided"); + return Result.failure(); + } + + Log.d(TAG, "Starting background sync: " + jobApiName); + + try { + RegistrationClientApp app = (RegistrationClientApp) getApplicationContext(); + AppComponent component = app.getAppComponent(); + + MasterDataSyncApi syncApi = component.masterDataSyncApi(); + SyncScheduler scheduler = component.syncScheduler(); + + AtomicBoolean syncSucceeded = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + syncApi.executeJobByApiName(jobApiName, getApplicationContext(), success -> { + syncSucceeded.set(Boolean.TRUE.equals(success)); + latch.countDown(); + }); + + boolean completed = latch.await(SYNC_TIMEOUT_MINUTES, TimeUnit.MINUTES); + if (!completed || !syncSucceeded.get()) { + Log.w(TAG, "Sync timed out or failed: " + jobApiName); + return Result.retry(); + } + + scheduler.scheduleJob(getApplicationContext(), jobApiName); + + Log.d(TAG, "Background sync completed: " + jobApiName); + return Result.success(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "Background sync interrupted: " + jobApiName, e); + return Result.retry(); + } catch (Exception e) { + Log.e(TAG, "Background sync failed: " + jobApiName, e); + return Result.retry(); + } + } +} diff --git a/android/app/src/main/java/io/mosip/registration_client/api_services/AuthenticationApi.java b/android/app/src/main/java/io/mosip/registration_client/api_services/AuthenticationApi.java index fcee48f24..48b8264f8 100644 --- a/android/app/src/main/java/io/mosip/registration_client/api_services/AuthenticationApi.java +++ b/android/app/src/main/java/io/mosip/registration_client/api_services/AuthenticationApi.java @@ -7,14 +7,8 @@ package io.mosip.registration_client.api_services; -import static androidx.core.content.ContextCompat.getSystemService; - -import android.app.AlarmManager; -import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; -import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; @@ -37,8 +31,8 @@ import io.mosip.registration.clientmanager.spi.SyncRestService; import io.mosip.registration.clientmanager.util.SyncRestUtil; import io.mosip.registration_client.R; -import io.mosip.registration_client.UploadBackgroundService; import io.mosip.registration_client.model.AuthResponsePigeon; +import io.mosip.registration_client.utils.SyncScheduler; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -52,6 +46,7 @@ public class AuthenticationApi implements AuthResponsePigeon.AuthResponseApi { AuditManagerService auditManagerService; SharedPreferences sharedPreferences; GlobalParamRepository globalParamRepository; + SyncScheduler syncScheduler; public static final String IS_OFFICER = "is_officer"; public static final String IS_SUPERVISOR = "is_supervisor"; public static final String IS_DEFAULT = "is_default"; @@ -63,14 +58,20 @@ public class AuthenticationApi implements AuthResponsePigeon.AuthResponseApi { @Inject - public AuthenticationApi(Context context, SyncRestService syncRestService, SyncRestUtil syncRestFactory, - LoginService loginService, AuditManagerService auditManagerService, GlobalParamRepository globalParamRepository) { + public AuthenticationApi(Context context, + SyncRestService syncRestService, + SyncRestUtil syncRestFactory, + LoginService loginService, + AuditManagerService auditManagerService, + GlobalParamRepository globalParamRepository, + SyncScheduler syncScheduler) { this.context = context; this.syncRestService = syncRestService; this.syncRestFactory = syncRestFactory; this.loginService = loginService; this.auditManagerService = auditManagerService; this.globalParamRepository = globalParamRepository; + this.syncScheduler = syncScheduler; sharedPreferences = this.context. getSharedPreferences( this.context.getString(R.string.app_name), @@ -221,31 +222,14 @@ public void logout(@NonNull AuthResponsePigeon.Result result) { @Override public void stopAlarmService(@NonNull AuthResponsePigeon.Result result) { String resultString = ""; - try{ - Intent intent = new Intent(context, UploadBackgroundService.class); - PendingIntent pendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - pendingIntent = PendingIntent.getForegroundService( - this.context, - 0, // Request code - intent, - PendingIntent.FLAG_IMMUTABLE - ); - } else { - pendingIntent = PendingIntent.getService( - this.context, - 0, // Request code - intent, - PendingIntent.FLAG_UPDATE_CURRENT - ); + try { + if (syncScheduler != null) { + syncScheduler.cancelAllJobs(context); } - AlarmManager alarmManager = (AlarmManager) getSystemService(context, AlarmManager.class); - assert alarmManager != null; - alarmManager.cancel(pendingIntent); resultString = "Success"; - }catch (Exception e){ - resultString = "Fail to Stop Alarm Service"; - Log.e(getClass().getSimpleName(), "Failed to stop alarm service", e); + } catch (Exception e) { + resultString = "Fail to Stop Sync Service"; + Log.e(getClass().getSimpleName(), "Failed to stop sync service", e); } result.success(resultString); } diff --git a/android/app/src/main/java/io/mosip/registration_client/api_services/MasterDataSyncApi.java b/android/app/src/main/java/io/mosip/registration_client/api_services/MasterDataSyncApi.java index 7a561b7b6..a6a5aa1a3 100644 --- a/android/app/src/main/java/io/mosip/registration_client/api_services/MasterDataSyncApi.java +++ b/android/app/src/main/java/io/mosip/registration_client/api_services/MasterDataSyncApi.java @@ -12,15 +12,9 @@ import static io.mosip.registration.clientmanager.service.MasterDataServiceImpl.REG_APP_ID; import android.app.Activity; -import android.app.AlarmManager; -import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import android.util.Log; import android.widget.Toast; @@ -34,6 +28,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import javax.inject.Inject; import javax.inject.Singleton; @@ -74,8 +69,8 @@ import io.mosip.registration.keymanager.spi.CertificateManagerService; import io.mosip.registration.keymanager.spi.ClientCryptoManagerService; import io.mosip.registration_client.utils.BatchJob; +import io.mosip.registration_client.utils.SyncScheduler; import io.mosip.registration_client.MainActivity; -import io.mosip.registration_client.UploadBackgroundService; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodChannel; import io.mosip.registration_client.model.MasterDataSyncPigeon; @@ -118,7 +113,8 @@ public class MasterDataSyncApi implements MasterDataSyncPigeon.SyncApi { private Activity activity; - BatchJob batchJob; + private final BatchJob batchJob; + private final SyncScheduler syncScheduler; private BinaryMessenger flutterBinaryMessenger; private final Object restartLock = new Object(); @@ -140,7 +136,8 @@ public MasterDataSyncApi(ClientCryptoManagerService clientCryptoManagerService, AuditManagerService auditManagerService, MasterDataService masterDataService, PacketService packetService, - GlobalParamDao globalParamDao, FileSignatureDao fileSignatureDao, PreRegistrationDataSyncService preRegistrationDataSyncService, LocalConfigService localConfigService) { + GlobalParamDao globalParamDao, FileSignatureDao fileSignatureDao, PreRegistrationDataSyncService preRegistrationDataSyncService, LocalConfigService localConfigService, + BatchJob batchJob, SyncScheduler syncScheduler) { this.clientCryptoManagerService = clientCryptoManagerService; this.machineRepository = machineRepository; this.registrationCenterRepository = registrationCenterRepository; @@ -167,11 +164,13 @@ public MasterDataSyncApi(ClientCryptoManagerService clientCryptoManagerService, this.fileSignatureDao = fileSignatureDao; this.preRegistrationDataSyncService = preRegistrationDataSyncService; this.localConfigService = localConfigService; + this.batchJob = batchJob; + this.syncScheduler = syncScheduler; } - public void setCallbackActivity(MainActivity mainActivity, BatchJob batchJob, BinaryMessenger flutterBinaryMessenger) { + public void setCallbackActivity(MainActivity mainActivity, BinaryMessenger flutterBinaryMessenger) { this.activity = mainActivity; - this.batchJob = batchJob; + this.batchJob.setCallbackActivity(mainActivity); this.flutterBinaryMessenger = flutterBinaryMessenger; } @@ -410,43 +409,8 @@ public void getSyncAndUploadInProgressStatus(@NonNull MasterDataSyncPigeon.Resul } void resetAlarm(String api) { - Intent intent = new Intent(activity, UploadBackgroundService.class); - PendingIntent pendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - pendingIntent = PendingIntent.getForegroundService( - activity, - 0, // Request code - intent, - PendingIntent.FLAG_IMMUTABLE - ); - } else { - pendingIntent = PendingIntent.getService( - activity, - 0, // Request code - intent, - PendingIntent.FLAG_UPDATE_CURRENT - ); - } - AlarmManager alarmManager = (AlarmManager) activity.getSystemService(Context.ALARM_SERVICE); - if (alarmManager != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { - Intent permissionIntent = new Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); - permissionIntent.setData(Uri.fromParts("package", activity.getPackageName(), null)); - activity.startActivity(permissionIntent); - } - long alarmTime = batchJob.getIntervalMillis(api); - long currentTime = System.currentTimeMillis(); - long delay = alarmTime > currentTime ? alarmTime - currentTime : alarmTime - currentTime; - Log.d(getClass().getSimpleName(), String.valueOf(delay) + " Next Execution"); - -// alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP,System.currentTimeMillis(), 30000, pendingIntent); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } else { - alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delay, pendingIntent); - } + if (syncScheduler != null) { + syncScheduler.scheduleJob(context, api); } } @@ -595,13 +559,9 @@ public void modifyJobCronExpression(@NonNull String jobId, @NonNull String cronE // This will reschedule JobScheduler jobs with new cron jobManagerService.refreshJobStatus(jobDef); - // For AlarmManager-based jobs (like batch jobs), reschedule immediately - if (jobDef.getApiName() != null && activity != null) { - // Reschedule using MainActivity's createBackgroundTask via broadcast - Intent rescheduleIntent = new Intent("RESCHEDULE_JOB"); - rescheduleIntent.putExtra(UploadBackgroundService.EXTRA_JOB_API_NAME, jobDef.getApiName()); - context.sendBroadcast(rescheduleIntent); - Log.d(TAG, "Sent reschedule broadcast for job: " + jobDef.getApiName()); + if (jobDef.getApiName() != null && syncScheduler != null) { + syncScheduler.scheduleJob(context, jobDef.getApiName()); + Log.d(TAG, "Rescheduled job via WorkManager: " + jobDef.getApiName()); } } @@ -624,26 +584,43 @@ public void getValue(@NonNull String name, @NonNull MasterDataSyncPigeon.Result< } } - // Execute job based on API name public void executeJobByApiName(String jobApiName, Context context) { + executeJobByApiName(jobApiName, context, null); + } + + /** + * Execute a sync job identified by its API name. The optional {@code onComplete} + * callback will be invoked exactly once when the job finishes, with a boolean + * indicating overall success or failure. + */ + public void executeJobByApiName(String jobApiName, Context context, Consumer onComplete) { new Thread(() -> { try { - - // Get job ID from database for tracking last/next sync String jobId = getJobIdByApiName(jobApiName); onSyncJobStart(); - - // Execute appropriate sync job + Log.d(getClass().getSimpleName(), "Starting: " + jobApiName); switch (jobApiName) { case "registrationPacketUploadJob": - batchJob.syncRegistrationPackets(context, () -> { + batchJob.uploadRegistrationPackets(context, () -> { Log.d(getClass().getSimpleName(), "Registration packet upload job completed"); + masterDataService.logLastSyncCompletionDateTime(jobId); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); + }); + break; + case "registrationPacketSyncJob": + batchJob.syncRegistrationPackets(context, () -> { + Log.d(getClass().getSimpleName(), "Registration packet sync job completed"); + masterDataService.logLastSyncCompletionDateTime(jobId); + onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); }); break; case "packetSyncStatusJob": packetService.syncAllPacketStatus(); + masterDataService.logLastSyncCompletionDateTime(jobId); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); break; case "masterSyncJob": masterDataService.syncMasterData(() -> { @@ -651,6 +628,7 @@ public void executeJobByApiName(String jobApiName, Context context) { String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, 0, false, jobId); break; case "synchConfigDataJob": @@ -659,6 +637,7 @@ public void executeJobByApiName(String jobApiName, Context context) { String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, false, jobId); break; case "userDetailServiceJob": @@ -667,6 +646,7 @@ public void executeJobByApiName(String jobApiName, Context context) { String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, false, jobId); break; case "keyPolicySyncJob": @@ -677,34 +657,37 @@ public void executeJobByApiName(String jobApiName, Context context) { String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, REG_APP_ID, centerMachineDto.getMachineRefId(), REG_APP_ID, centerMachineDto.getMachineRefId(), false, jobId); } else { Log.w(getClass().getSimpleName(), "Skipping keyPolicySyncJob - machine details not available"); onSyncJobComplete(jobId, false, false); + notifyComplete(onComplete, false); } break; case "publicKeySyncJob": - // Public key sync for KERNEL app (SIGN certificates) masterDataService.syncCertificate(() -> { Log.d(getClass().getSimpleName(), "Public key sync callback"); String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, KERNEL_APP_ID, "SIGN", "SERVER-RESPONSE", "SIGN-VERIFY", false, jobId); break; case "syncCertificateJob": - // CA certificate sync masterDataService.syncCACertificates(() -> { Log.d(getClass().getSimpleName(), "CA cert sync callback"); String errorCode = masterDataService.onResponseComplete(); boolean success = errorCode == null || errorCode.isEmpty(); onSyncJobComplete(jobId, success, false); + notifyComplete(onComplete, success); }, false, jobId); break; case "preRegistrationDataSyncJob": preRegistrationDataSyncService.fetchPreRegistrationIds(() -> { Log.i(TAG, "Application Id's Sync Completed"); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); }, jobId); break; @@ -712,12 +695,14 @@ public void executeJobByApiName(String jobApiName, Context context) { auditManagerService.deleteAuditLogs(); masterDataService.logLastSyncCompletionDateTime(jobId); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); break; case "preRegistrationPacketDeletionJob": preRegistrationDataSyncService.fetchAndDeleteRecords(); masterDataService.logLastSyncCompletionDateTime(jobId); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); break; case "registrationDeletionJob": @@ -725,19 +710,32 @@ public void executeJobByApiName(String jobApiName, Context context) { masterDataService.logLastSyncCompletionDateTime(jobId); Log.i(TAG, "Registration packet deletion job completed"); onSyncJobComplete(jobId, true, false); + notifyComplete(onComplete, true); break; default: Log.w(getClass().getSimpleName(), "Unknown job: " + jobApiName); onSyncJobComplete(jobId, false, false); + notifyComplete(onComplete, false); } Log.d(getClass().getSimpleName(), "Completed: " + jobApiName); } catch (Exception e) { onSyncJobComplete(getJobIdByApiName(jobApiName), false, false); Log.e(getClass().getSimpleName(), "Job failed: " + jobApiName, e); + notifyComplete(onComplete, false); } }).start(); } + private void notifyComplete(Consumer 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) {