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
124 changes: 95 additions & 29 deletions app/src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs

import android.app.Notification
import android.content.Context
import androidx.work.Worker
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.utils.ForegroundServiceHelper
import com.owncloud.android.R
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FilesSyncHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* This work is triggered when OS detects change in media folders.
Expand All @@ -22,52 +31,109 @@ import com.owncloud.android.utils.FilesSyncHelper
* This job must not be started on API < 24.
*/
class ContentObserverWork(
appContext: Context,
private val context: Context,
private val params: WorkerParameters,
private val syncedFolderProvider: SyncedFolderProvider,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
) : CoroutineWorker(context, params) {

override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
companion object {
private const val TAG = "🔍" + "ContentObserverWork"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER
private const val NOTIFICATION_ID = 774
}

@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerName = BackgroundJobManagerImpl.formatClassTag(this@ContentObserverWork::class)
backgroundJobManager.logStartOfWorker(workerName)
Log_OC.d(TAG, "started")

try {
if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "📸 content observer detected file changes.")

val notificationTitle = context.getString(R.string.content_observer_work_notification_title)
val notification = createNotification(notificationTitle)
updateForegroundInfo(notification)
checkAndTriggerAutoUpload()

if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "File-sync Content Observer detected files change")
checkAndStartFileSyncJob()
backgroundJobManager.startMediaFoldersDetectionJob()
} else {
Log_OC.d(TAG, "triggeredContentUris empty")
// prevent worker fail because of another worker
try {
backgroundJobManager.startMediaFoldersDetectionJob()
} catch (e: Exception) {
Log_OC.d(TAG, "⚠️ media folder detection job failed :$e")
}
} else {
Log_OC.d(TAG, "⚠️ triggeredContentUris is empty — nothing to sync.")
}

rescheduleSelf()

val result = Result.success()
backgroundJobManager.logEndOfWorker(workerName, result)
Log_OC.d(TAG, "finished")
result
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Exception in ContentObserverWork: ${e.message}", e)
Result.retry()
}
recheduleSelf()
}

val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}

private fun recheduleSelf() {
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_find_in_page)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()

/**
* Re-schedules this observer to ensure continuous monitoring of media changes.
*/
private fun rescheduleSelf() {
Log_OC.d(TAG, "🔁 Rescheduling ContentObserverWork for continued observation.")
backgroundJobManager.scheduleContentObserverJob()
}

private fun checkAndStartFileSyncJob() {
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
val changedFiles = mutableListOf<String>()
for (uri in params.triggeredContentUris) {
changedFiles.add(uri.toString())
}
private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) {
if (powerManagementService.isPowerSavingEnabled) {
Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.")
return@withContext
}

val enabledFoldersCount = syncedFolderProvider.countEnabledSyncedFolders()
if (enabledFoldersCount <= 0) {
Log_OC.w(TAG, "🚫 No enabled synced folders found — skipping file sync.")
return@withContext
}

val contentUris = params.triggeredContentUris.map { uri -> uri.toString() }.toTypedArray()
Log_OC.d(TAG, "📄 Content uris detected")

try {
FilesSyncHelper.startFilesSyncForAllFolders(
syncedFolderProvider,
backgroundJobManager,
false,
changedFiles.toTypedArray()
contentUris
)
} else {
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).")
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Failed to start auto upload for changed files: ${e.message}", e)
}
}

companion object {
val TAG: String = ContentObserverWork::class.java.simpleName
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/com/owncloud/android/MainApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,13 @@ public static void notificationChannels() {
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS,
R.string.notification_channel_offline_operations_name_short,
R.string.notification_channel_offline_operations_description, context);

createChannel(notificationManager,
NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER,
R.string.notification_channel_content_observer_name_short,
R.string.notification_channel_content_observer_description,
context,
NotificationManager.IMPORTANCE_LOW);
} else {
Log_OC.e(TAG, "Notification manager is null");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ object NotificationUtils {
const val NOTIFICATION_CHANNEL_PUSH: String = "NOTIFICATION_CHANNEL_PUSH"
const val NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS: String = "NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS"
const val NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS: String = "NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS"
const val NOTIFICATION_CHANNEL_CONTENT_OBSERVER: String = "NOTIFICATION_CHANNEL_CONTENT_OBSERVER"

fun createUploadNotificationTag(file: OCFile): String =
createUploadNotificationTag(file.remotePath, file.storagePath)
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,9 @@
<string name="notification_channel_offline_operations_name_short">Offline operations</string>
<string name="notification_channel_offline_operations_description">Shows progress of offline file operations</string>

<string name="content_observer_work_notification_title">Detecting content changes</string>
Copy link
Collaborator Author

@alperozturk96 alperozturk96 Oct 10, 2025

Choose a reason for hiding this comment

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

@kra-mo

The notification for ContentObserverWork is only displayed when the user has enabled a folder for auto-upload, such as the DCIM directory and when the user takes a photo using the Camera app, the content observer worker will trigger and display the notification if any new files there. In practice, the notification will appear briefly.

Notification is mandatory to make ContentObserverWork long-running task. ContentObserverWork triggers to AutoUpload thus it is needed to making it a long-running task ensures all triggers are executed.

Could you review the current wording for the notification title, channel name, and description?

<string name="notification_channel_content_observer_name_short">Content observer</string>
<string name="notification_channel_content_observer_description">Detects local file changes</string>

<string name="account_not_found">Account not found!</string>

Expand Down
114 changes: 62 additions & 52 deletions app/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
Expand All @@ -12,6 +13,7 @@ import android.net.Uri
import androidx.work.WorkerParameters
import com.nextcloud.client.device.PowerManagementService
import com.owncloud.android.datamodel.SyncedFolderProvider
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
Expand Down Expand Up @@ -42,9 +44,9 @@ class ContentObserverWorkTest {

@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
MockitoAnnotations.openMocks(this)
worker = ContentObserverWork(
appContext = context,
context = context,
params = params,
syncedFolderProvider = folderProvider,
powerManagementService = powerManagementService,
Expand All @@ -56,70 +58,78 @@ class ContentObserverWorkTest {

@Test
fun job_reschedules_self_after_each_run_unconditionally() {
// GIVEN
// nothing to sync
whenever(params.triggeredContentUris).thenReturn(emptyList())

// WHEN
// worker is called
worker.doWork()

// THEN
// worker reschedules itself unconditionally
verify(backgroundJobManager).scheduleContentObserverJob()
runBlocking {
// GIVEN
// nothing to sync
whenever(params.triggeredContentUris).thenReturn(emptyList())

// WHEN
// worker is called
worker.doWork()

// THEN
// worker reschedules itself unconditionally
verify(backgroundJobManager).scheduleContentObserverJob()
}
}

@Test
@Ignore("TODO: needs further refactoring")
fun sync_is_triggered() {
// GIVEN
// power saving is disabled
// some folders are configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager).sheduleFilesSync() or something like this
runBlocking {
// GIVEN
// power saving is disabled
// some folders are configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager).sheduleFilesSync() or something like this
}
}

@Test
@Ignore("TODO: needs further refactoring")
fun sync_is_not_triggered_under_power_saving_mode() {
// GIVEN
// power saving is enabled
// some folders are configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(true)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this)
runBlocking {
// GIVEN
// power saving is enabled
// some folders are configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(true)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(1)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this)
}
}

@Test
@Ignore("TODO: needs further refactoring")
fun sync_is_not_triggered_if_no_folder_are_synced() {
// GIVEN
// power saving is disabled
// no folders configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(0)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this)
runBlocking {
// GIVEN
// power saving is disabled
// no folders configured for syncing
whenever(powerManagementService.isPowerSavingEnabled).thenReturn(false)
whenever(folderProvider.countEnabledSyncedFolders()).thenReturn(0)

// WHEN
// worker is called
worker.doWork()

// THEN
// sync job is scheduled
// TO DO: verify(backgroundJobManager, never()).sheduleFilesSync() or something like this)
}
}
}
Loading