Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/src/org/commcare/CommCareApplication.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package org.commcare;

import static org.commcare.AppUtils.getCurrentAppId;

import android.annotation.SuppressLint;
import android.app.ActivityManager;
import android.app.Application;
Expand Down Expand Up @@ -47,6 +45,7 @@
import org.commcare.android.logging.ForceCloseLogEntry;
import org.commcare.android.logging.ForceCloseLogger;
import org.commcare.android.logging.ReportingUtils;
import org.commcare.connect.workers.ConnectReleaseTogglesWorker;
import org.commcare.core.graph.util.GraphUtil;
import org.commcare.core.interfaces.HttpResponseProcessor;
import org.commcare.core.network.AuthInfo;
Expand Down Expand Up @@ -155,6 +154,8 @@
import okhttp3.MultipartBody;
import okhttp3.RequestBody;

import static org.commcare.AppUtils.getCurrentAppId;

public class CommCareApplication extends Application implements LifecycleEventObserver {

private static final String TAG = CommCareApplication.class.getSimpleName();
Expand Down Expand Up @@ -278,6 +279,8 @@ public void onCreate() {
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
NotificationsSyncWorkerManager.schedulePeriodicPushNotificationRetrievalChecked(
CommCareApplication.instance());

ConnectReleaseTogglesWorker.Companion.schedulePeriodicFetch(CommCareApplication.instance());
}

protected void loadSqliteLibs() {
Expand Down
5 changes: 4 additions & 1 deletion app/src/org/commcare/connect/PersonalIdManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.commcare.connect.network.TokenExceptionHandler;
import org.commcare.connect.network.TokenUnavailableException;
import org.commcare.connect.workers.ConnectHeartbeatWorker;
import org.commcare.connect.workers.ConnectReleaseTogglesWorker;
import org.commcare.core.network.AuthInfo;
import org.commcare.dalvik.R;
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
Expand Down Expand Up @@ -210,6 +211,7 @@ public void userUnlockedPersonalId(){
scheduleHeartbeat();
NotificationsSyncWorkerManager.schedulePeriodicPushNotificationRetrieval(CommCareApplication.instance());
CrashUtil.registerUserData();
ConnectReleaseTogglesWorker.Companion.schedulePeriodicFetch(CommCareApplication.instance());
}

public void handleFinishedActivity(CommCareActivity<?> activity, int resultCode) {
Expand All @@ -219,7 +221,6 @@ public void handleFinishedActivity(CommCareActivity<?> activity, int resultCode)
}
}


public void forgetUser(String reason) {
if (ConnectDatabaseHelper.dbExists()) {
FirebaseAnalyticsUtil.reportPersonalIdAccountForgotten(reason);
Expand All @@ -232,6 +233,8 @@ public void forgetUser(String reason) {

// remove notification read / unread preferences
NotificationPrefs.INSTANCE.removeNotificationReadPref(CommCareApplication.instance());

ConnectReleaseTogglesWorker.Companion.cancelPeriodicFetch(CommCareApplication.instance());
}

public AuthInfo.TokenAuth getConnectToken() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.commcare.android.database.connect.models.ConnectJobRecord
import org.commcare.connect.database.ConnectJobUtils
import org.commcare.connect.network.base.BaseApiResponseParser
import org.commcare.connect.network.connect.models.ConnectOpportunitiesResponseModel
import org.commcare.connect.workers.ConnectReleaseTogglesWorker
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil
import org.commcare.models.connect.ConnectLoginJobListModel
import org.javarosa.core.io.StreamsUtil
Expand All @@ -14,17 +15,19 @@ import org.json.JSONException
import org.json.JSONObject
import java.io.InputStream

class ConnectOpportunitiesParser<T>() : BaseApiResponseParser<T> {


override fun parse(responseCode: Int, responseData: InputStream, anyInputObject: Any?): T {
class ConnectOpportunitiesParser<T> : BaseApiResponseParser<T> {
override fun parse(
responseCode: Int,
responseData: InputStream,
anyInputObject: Any?,
): T {
val corruptJobs: ArrayList<ConnectLoginJobListModel> = ArrayList()
val jobs: ArrayList<ConnectJobRecord> = ArrayList()
try {
responseData.use { `in` ->
val responseAsString = String(StreamsUtil.inputStreamToByteArray(`in`))
if (!responseAsString.isEmpty()) {
//Parse the JSON
// Parse the JSON
val json = JSONArray(responseAsString)
for (i in 0 until json.length()) {
var obj: JSONObject? = null
Expand All @@ -37,7 +40,14 @@ class ConnectOpportunitiesParser<T>() : BaseApiResponseParser<T> {
}
}

val newJobs = ConnectJobUtils.storeJobs(anyInputObject as Context, jobs, true)
val context = anyInputObject as Context
val newJobs = ConnectJobUtils.storeJobs(context, jobs, true)

// Fetch feature release toggles if there is a new job.
if (newJobs > 0) {
ConnectReleaseTogglesWorker.scheduleOneTimeFetch(context)
}

reportApiCall(true, jobs.size, newJobs)
}
}
Expand All @@ -46,15 +56,20 @@ class ConnectOpportunitiesParser<T>() : BaseApiResponseParser<T> {
throw RuntimeException(e)
}
return ConnectOpportunitiesResponseModel(jobs, corruptJobs) as T

}


private fun reportApiCall(success: Boolean, totalJobs: Int, newJobs: Int) {
private fun reportApiCall(
success: Boolean,
totalJobs: Int,
newJobs: Int,
) {
FirebaseAnalyticsUtil.reportCccApiJobs(success, totalJobs, newJobs)
}

private fun handleCorruptJob(obj: JSONObject?,corruptJobs: ArrayList<ConnectLoginJobListModel>) {
private fun handleCorruptJob(
obj: JSONObject?,
corruptJobs: ArrayList<ConnectLoginJobListModel>,
) {
if (obj != null) {
try {
corruptJobs.add(createJobModel(ConnectJobRecord.corruptJobFromJson(obj)))
Expand All @@ -64,7 +79,5 @@ class ConnectOpportunitiesParser<T>() : BaseApiResponseParser<T> {
}
}

private fun createJobModel(job: ConnectJobRecord): ConnectLoginJobListModel {
return ConnectLoginJobListModel(job.title, job)
}
}
private fun createJobModel(job: ConnectJobRecord): ConnectLoginJobListModel = ConnectLoginJobListModel(job.title, job)
}
100 changes: 100 additions & 0 deletions app/src/org/commcare/connect/workers/ConnectReleaseTogglesWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package org.commcare.connect.workers

import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.commcare.android.database.connect.models.ConnectReleaseToggleRecord
import org.commcare.connect.PersonalIdManager
import org.commcare.connect.network.connectId.PersonalIdApiHandler
import org.javarosa.core.services.Logger
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

class ConnectReleaseTogglesWorker(
val context: Context,
workerParameters: WorkerParameters,
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
val personalIdManager = PersonalIdManager.getInstance()
if (!personalIdManager.isloggedIn()) {
return Result.failure()
}

val user = personalIdManager.getUser(context)

return suspendCoroutine { continuation ->
object : PersonalIdApiHandler<List<ConnectReleaseToggleRecord>>() {
override fun onFailure(
errorCode: PersonalIdOrConnectApiErrorCodes,
t: Throwable?,
) {
Logger.exception("Failed to get feature release toggles in background!", t)

if (errorCode == PersonalIdOrConnectApiErrorCodes.NETWORK_ERROR) {
continuation.resume(Result.retry())
} else {
continuation.resume(Result.failure())
}
}

override fun onSuccess(data: List<ConnectReleaseToggleRecord>) {
continuation.resume(Result.success())
}
}.getReleaseToggles(context, user.userId, user.password)
}
}

companion object {
private const val WORK_NAME = "connect_release_toggles_fetch_worker"

fun scheduleOneTimeFetch(context: Context) {
val workRequest =
OneTimeWorkRequest
.Builder(ConnectReleaseTogglesWorker::class.java)
.setConstraints(getWorkConstraints())
.build()

WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
workRequest,
)
}

fun schedulePeriodicFetch(context: Context) {
val workRequest =
PeriodicWorkRequest
.Builder(
ConnectReleaseTogglesWorker::class.java,
4,
TimeUnit.HOURS,
).setConstraints(getWorkConstraints())
.build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
workRequest,
)
}

fun cancelPeriodicFetch(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Comment on lines +55 to +90
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

WorkManager enqueueUniqueWork enqueueUniquePeriodicWork same work name collision Android

💡 Result:

Yes—enqueueUniqueWork() and enqueueUniquePeriodicWork() share the same “unique work name” namespace (within your app), so using the same uniqueWorkName can collide.

  • Android’s docs describe unique work as “only one instance of work with a particular name at a time”, and say it applies to both one-time and periodic work. So the name is intended to be globally unique across your app’s WorkManager, not “unique per type”. [1]
  • Internally, WorkManager builds periodic unique work using the same “unique work chain” mechanism as one-time work: it converts ExistingPeriodicWorkPolicy to an ExistingWorkPolicy and creates a WorkContinuationImpl(this, uniqueWorkName, existingWorkPolicy, ...). That means the periodic work is inserted into the same unique-name conflict resolution path as one-time unique work. [3]

What happens on collision

If you call one after the other with the same name, whichever you enqueue second will apply its existing-work policy against whatever is already enqueued under that name:

  • One-time uses ExistingWorkPolicy (KEEP, REPLACE, APPEND, etc.). [2]
  • Periodic uses ExistingPeriodicWorkPolicy (KEEP, CANCEL_AND_REENQUEUE, and newer UPDATE, etc.). [4]

Recommendation

Use different unique names for periodic vs one-time work (e.g., "sync_periodic" vs "sync_oneoff"), and use tags if you want to group/observe/cancel both kinds together. [1]

Sources:
[1] Android Developers – Managing work / Unique Work https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/manage-work
[2] Android Developers – WorkManager.enqueueUniqueWork API https://developer.android.com/reference/androidx/work/WorkManager
[3] AOSP/AndroidX source – WorkManagerImpl.createWorkContinuationForUniquePeriodicWork(...) uses WorkContinuationImpl(..., uniqueWorkName, existingWorkPolicy, ...) https://android.googlesource.com/platform/frameworks/support/+/3739ee65f12c3d007d44999afb6786b7575aa50a/work/workmanager/src/main/java/androidx/work/impl/WorkManagerImpl.java
[4] Android Developers – ExistingPeriodicWorkPolicy https://developer.android.com/reference/androidx/work/ExistingPeriodicWorkPolicy


🌐 Web query:

Android WorkManager unique work name global namespace one-time periodic

💡 Result:

  • Scope (“global namespace”): A uniqueWorkName is only “global” within your app’s WorkManager database (your app/package sandbox)—it will not collide with other apps’ work, even if they use the same string name. [1]
  • One-time vs periodic: WorkManager supports unique names for both:
    • enqueueUniqueWork(name, ExistingWorkPolicy, OneTimeWorkRequest) for one-time unique work/chains [2]
    • enqueueUniquePeriodicWork(name, ExistingPeriodicWorkPolicy, PeriodicWorkRequest) for periodic unique work [2]
      WorkManager’s “unique work” concept is intended to ensure only one active instance for a given unique name. [3]
  • Practical guidance: Because uniqueness is enforced by name, treat names as a single app-wide namespace and don’t reuse the same uniqueWorkName for unrelated work, especially mixing “one-time” and “periodic” concepts—use distinct names/prefixes. [2] [3]
  • Policy differences (important when a name already exists):
    • One-time uses ExistingWorkPolicy (e.g., KEEP, REPLACE, APPEND, etc.). [2]
    • Periodic uses ExistingPeriodicWorkPolicy (e.g., KEEP, REPLACE, UPDATE). [2] [4]

Sources:
[1] Stack Overflow (app sandbox / no cross-app collisions) (stackoverflow.com)
[2] Android Developers – WorkManager API reference (developer.android.com)
[3] Android Developers – “Managing work” (Unique Work concept) (developer.android.com)
[4] Android Developers – ExistingPeriodicWorkPolicy (developer.android.com)

Citations:


🏁 Script executed:

cat -n app/src/org/commcare/connect/workers/ConnectReleaseTogglesWorker.kt

Repository: dimagi/commcare-android

Length of output: 4349


Use separate unique work names for one-time and periodic fetches.

Both scheduleOneTimeFetch() and schedulePeriodicFetch() share the same unique work name namespace in WorkManager. When both are used, the second enqueue silently applies its ExistingWorkPolicy against the first, potentially skipping one of the fetches. This defeats the intent of having independent fetch mechanisms.

🛠️ Suggested fix (separate names)
-        private const val WORK_NAME = "connect_release_toggles_fetch_worker"
+        private const val WORK_NAME_ONE_TIME = "connect_release_toggles_fetch_worker_once"
+        private const val WORK_NAME_PERIODIC = "connect_release_toggles_fetch_worker_periodic"
@@
-            WorkManager.getInstance(context).enqueueUniqueWork(
-                WORK_NAME,
+            WorkManager.getInstance(context).enqueueUniqueWork(
+                WORK_NAME_ONE_TIME,
                 ExistingWorkPolicy.KEEP,
                 workRequest,
             )
@@
-            WorkManager.getInstance(context).enqueueUniquePeriodicWork(
-                WORK_NAME,
+            WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+                WORK_NAME_PERIODIC,
                 ExistingPeriodicWorkPolicy.KEEP,
                 workRequest,
             )
@@
-            WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
+            WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_PERIODIC)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
companion object {
private const val WORK_NAME = "connect_release_toggles_fetch_worker"
fun scheduleOneTimeFetch(context: Context) {
val workRequest =
OneTimeWorkRequest
.Builder(ConnectReleaseTogglesWorker::class.java)
.setConstraints(getWorkConstraints())
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
workRequest,
)
}
fun schedulePeriodicFetch(context: Context) {
val workRequest =
PeriodicWorkRequest
.Builder(
ConnectReleaseTogglesWorker::class.java,
4,
TimeUnit.HOURS,
).setConstraints(getWorkConstraints())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
workRequest,
)
}
fun cancelPeriodicFetch(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
companion object {
private const val WORK_NAME_ONE_TIME = "connect_release_toggles_fetch_worker_once"
private const val WORK_NAME_PERIODIC = "connect_release_toggles_fetch_worker_periodic"
fun scheduleOneTimeFetch(context: Context) {
val workRequest =
OneTimeWorkRequest
.Builder(ConnectReleaseTogglesWorker::class.java)
.setConstraints(getWorkConstraints())
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME_ONE_TIME,
ExistingWorkPolicy.KEEP,
workRequest,
)
}
fun schedulePeriodicFetch(context: Context) {
val workRequest =
PeriodicWorkRequest
.Builder(
ConnectReleaseTogglesWorker::class.java,
4,
TimeUnit.HOURS,
).setConstraints(getWorkConstraints())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
workRequest,
)
}
fun cancelPeriodicFetch(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_PERIODIC)
🤖 Prompt for AI Agents
In `@app/src/org/commcare/connect/workers/ConnectReleaseTogglesWorker.kt` around
lines 55 - 90, Both scheduleOneTimeFetch() and schedulePeriodicFetch() use the
same WORK_NAME causing WorkManager to treat them as the same unique work; create
two distinct constants (e.g. WORK_NAME_ONE_TIME and WORK_NAME_PERIODIC) and
update scheduleOneTimeFetch(), schedulePeriodicFetch(), and
cancelPeriodicFetch() to use the appropriate constant (use WORK_NAME_ONE_TIME in
scheduleOneTimeFetch, WORK_NAME_PERIODIC in schedulePeriodicFetch and in
cancelPeriodicFetch) so the one-time and periodic enqueues/cancellations operate
independently.

}

private fun getWorkConstraints() =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
}
}