Эх сурвалжийг харах

Migrate NContentObserverJob to WorkManager

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 жил өмнө
parent
commit
808c9098ea

+ 3 - 0
build.gradle

@@ -272,6 +272,8 @@ dependencies {
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation 'androidx.exifinterface:exifinterface:1.0.0'
     implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0"
+    implementation "androidx.work:work-runtime:2.2.0"
+    implementation "androidx.work:work-runtime-ktx:2.2.0"
     implementation 'com.github.albfernandez:juniversalchardet:2.0.3' // need this version for Android <7
     implementation 'com.google.code.findbugs:annotations:2.0.1'
     implementation 'commons-io:commons-io:2.6'
@@ -315,6 +317,7 @@ dependencies {
     ktlint "com.pinterest:ktlint:0.34.2"
     implementation 'org.conscrypt:conscrypt-android:2.2.1'
 
+
     // dependencies for local unit tests
     testImplementation 'junit:junit:4.12'
     testImplementation 'org.mockito:mockito-core:3.1.0'

+ 8 - 5
src/main/AndroidManifest.xml

@@ -164,11 +164,6 @@
             android:label="@string/app_name"
             android:theme="@style/Theme.ownCloud.Fullscreen" />
 
-        <service
-            android:name=".jobs.NContentObserverJob"
-            android:permission="android.permission.BIND_JOB_SERVICE" >
-        </service>
-
         <service
             android:name=".authentication.AccountAuthenticatorService"
             android:exported="true" >
@@ -272,6 +267,14 @@
             android:exported="true">
         </provider>
 
+        <!-- Disable WorkManager initialization. Whoever designed this, should pay closer attention -->
+        <!-- to "best before" dates in his fridge. -->
+        <provider
+            android:name="androidx.work.impl.WorkManagerInitializer"
+            android:authorities=".workmanager-init"
+            android:exported="false"
+            tools:node="remove" />
+
         <activity
             android:name=".authentication.AuthenticatorActivity"
             android:exported="true"

+ 3 - 1
src/main/java/com/nextcloud/client/di/AppComponent.java

@@ -24,6 +24,7 @@ import android.app.Application;
 
 import com.nextcloud.client.appinfo.AppInfoModule;
 import com.nextcloud.client.device.DeviceModule;
+import com.nextcloud.client.jobs.JobsModule;
 import com.nextcloud.client.network.NetworkModule;
 import com.nextcloud.client.onboarding.OnboardingModule;
 import com.nextcloud.client.preferences.PreferencesModule;
@@ -43,7 +44,8 @@ import dagger.android.support.AndroidSupportInjectionModule;
     NetworkModule.class,
     DeviceModule.class,
     OnboardingModule.class,
-    ViewModelModule.class
+    ViewModelModule.class,
+    JobsModule.class
 })
 @Singleton
 public interface AppComponent {

+ 6 - 1
src/main/java/com/nextcloud/client/di/AppModule.java

@@ -33,9 +33,9 @@ import com.nextcloud.client.account.CurrentAccountProvider;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.account.UserAccountManagerImpl;
 import com.nextcloud.client.core.AsyncRunner;
-import com.nextcloud.client.core.ThreadPoolAsyncRunner;
 import com.nextcloud.client.core.Clock;
 import com.nextcloud.client.core.ClockImpl;
+import com.nextcloud.client.core.ThreadPoolAsyncRunner;
 import com.nextcloud.client.device.DeviceInfo;
 import com.nextcloud.client.logger.FileLogHandler;
 import com.nextcloud.client.logger.Logger;
@@ -71,6 +71,11 @@ class AppModule {
         return application;
     }
 
+    @Provides
+    ContentResolver contentResolver(Context context) {
+        return context.getContentResolver();
+    }
+
     @Provides
     Resources resources(Application application) {
         return application.getResources();

+ 81 - 0
src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

@@ -0,0 +1,81 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.jobs
+
+import android.content.ContentResolver
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.work.ListenableWorker
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import com.nextcloud.client.device.DeviceInfo
+import com.nextcloud.client.device.PowerManagementService
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import javax.inject.Inject
+import javax.inject.Provider
+
+/**
+ * This factory is responsible for creating all background jobs and for injecting
+ * all jobs dependencies.
+ */
+class BackgroundJobFactory @Inject constructor(
+    private val preferences: AppPreferences,
+    private val contentResolver: ContentResolver,
+    private val powerManagerService: PowerManagementService,
+    private val backgroundJobManager: Provider<BackgroundJobManager>,
+    private val deviceInfo: DeviceInfo
+) : WorkerFactory() {
+
+    override fun createWorker(
+        context: Context,
+        workerClassName: String,
+        workerParameters: WorkerParameters
+    ): ListenableWorker? {
+
+        val workerClass = try {
+            Class.forName(workerClassName).kotlin
+        } catch (ex: ClassNotFoundException) {
+            null
+        }
+
+        return when (workerClass) {
+            ContentObserverWork::class -> createContentObserverJob(context, workerParameters)
+            else -> null // falls back to default factory
+        }
+    }
+
+    private fun createContentObserverJob(context: Context, workerParameters: WorkerParameters): ListenableWorker? {
+        val folderResolver = SyncedFolderProvider(contentResolver, preferences)
+        @RequiresApi(Build.VERSION_CODES.N)
+        if (deviceInfo.apiLevel >= Build.VERSION_CODES.N) {
+            return ContentObserverWork(
+                context,
+                workerParameters,
+                folderResolver,
+                powerManagerService,
+                backgroundJobManager.get()
+            )
+        } else {
+            return null
+        }
+    }
+}

+ 37 - 0
src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

@@ -0,0 +1,37 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.jobs
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+
+/**
+ * This interface allows to control, schedule and monitor all application
+ * long-running background tasks, such as periodic checks or synchronization.
+ */
+interface BackgroundJobManager {
+
+    /**
+     * Start content observer job that monitors changes in media folders
+     * and launches synchronization when needed.
+     */
+    @RequiresApi(Build.VERSION_CODES.N)
+    fun scheduleContentObserverJob()
+}

+ 54 - 0
src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt

@@ -0,0 +1,54 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.jobs
+
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import androidx.work.Constraints
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+
+internal class BackgroundJobManagerImpl(private val workManager: WorkManager) : BackgroundJobManager {
+
+    companion object {
+        const val TAG_CONTENT_SYNC = "content_sync"
+        const val MAX_CONTENT_TRIGGER_DELAY_MS = 1500L
+    }
+
+    @RequiresApi(Build.VERSION_CODES.N)
+    override fun scheduleContentObserverJob() {
+        val constrains = Constraints.Builder()
+            .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
+            .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
+            .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
+            .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
+            .setTriggerContentMaxDelay(MAX_CONTENT_TRIGGER_DELAY_MS, TimeUnit.MILLISECONDS)
+            .build()
+
+        val request = OneTimeWorkRequest.Builder(ContentObserverWork::class.java)
+            .setConstraints(constrains)
+            .addTag(TAG_CONTENT_SYNC)
+            .build()
+
+        workManager.enqueue(request)
+    }
+}

+ 85 - 0
src/main/java/com/nextcloud/client/jobs/ContentObserverWork.kt

@@ -0,0 +1,85 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.jobs
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.evernote.android.job.JobRequest
+import com.evernote.android.job.util.support.PersistableBundleCompat
+import com.nextcloud.client.device.PowerManagementService
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import com.owncloud.android.jobs.FilesSyncJob
+import com.owncloud.android.jobs.MediaFoldersDetectionJob
+
+/**
+ * This work is triggered when OS detects change in media folders.
+ *
+ * It fires media detection job and sync job and finishes immediately.
+ *
+ * This job must not be started on API < 24.
+ */
+@RequiresApi(Build.VERSION_CODES.N)
+class ContentObserverWork(
+    appContext: Context,
+    private val params: WorkerParameters,
+    private val syncerFolderProvider: SyncedFolderProvider,
+    private val powerManagementService: PowerManagementService,
+    private val backgroundJobManager: BackgroundJobManager
+) : Worker(appContext, params) {
+
+    override fun doWork(): Result {
+        if (params.triggeredContentUris.size > 0) {
+            checkAndStartFileSyncJob()
+            startMediaFolderDetectionJob()
+        }
+        recheduleSelf()
+        return Result.success()
+    }
+
+    private fun recheduleSelf() {
+        backgroundJobManager.scheduleContentObserverJob()
+    }
+
+    private fun checkAndStartFileSyncJob() {
+        val syncFolders = syncerFolderProvider.countEnabledSyncedFolders() > 0
+        if (!powerManagementService.isPowerSavingEnabled && syncFolders) {
+            val persistableBundleCompat = PersistableBundleCompat()
+            persistableBundleCompat.putBoolean(FilesSyncJob.SKIP_CUSTOM, true)
+
+            JobRequest.Builder(FilesSyncJob.TAG)
+                .startNow()
+                .setExtras(persistableBundleCompat)
+                .setUpdateCurrent(false)
+                .build()
+                .schedule()
+        }
+    }
+
+    private fun startMediaFolderDetectionJob() {
+        JobRequest.Builder(MediaFoldersDetectionJob.TAG)
+            .startNow()
+            .setUpdateCurrent(false)
+            .build()
+            .schedule()
+    }
+}

+ 50 - 0
src/main/java/com/nextcloud/client/jobs/JobsModule.kt

@@ -0,0 +1,50 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.jobs
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class JobsModule {
+
+    @Provides
+    @Singleton
+    fun backgroundJobManager(context: Context, factory: BackgroundJobFactory): BackgroundJobManager {
+        val configuration = Configuration.Builder()
+            .setWorkerFactory(factory)
+            .build()
+
+        val contextWrapper = object : ContextWrapper(context) {
+            override fun getApplicationContext(): Context {
+                return this
+            }
+        }
+
+        WorkManager.initialize(contextWrapper, configuration)
+        val wm = WorkManager.getInstance(context)
+        return BackgroundJobManagerImpl(wm)
+    }
+}

+ 20 - 4
src/main/java/com/owncloud/android/MainApp.java

@@ -49,6 +49,7 @@ import com.nextcloud.client.device.PowerManagementService;
 import com.nextcloud.client.di.ActivityInjector;
 import com.nextcloud.client.di.DaggerAppComponent;
 import com.nextcloud.client.errorhandling.ExceptionHandler;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.logger.LegacyLoggerAdapter;
 import com.nextcloud.client.logger.Logger;
 import com.nextcloud.client.network.ConnectivityService;
@@ -156,6 +157,9 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
     @Inject
     AppInfo appInfo;
 
+    @Inject
+    BackgroundJobManager backgroundJobManager;
+
     private PassCodeManager passCodeManager;
 
     @SuppressWarnings("unused")
@@ -184,6 +188,14 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
         return powerManagementService;
     }
 
+    /**
+     * Temporary getter enabling intermediate refactoring.
+     * TODO: remove when FileSyncHelper is refactored/removed
+     */
+    public BackgroundJobManager getBackgroundJobManager() {
+        return backgroundJobManager;
+    }
+
     private String getAppProcessName() {
         String processName = "";
         if(Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
@@ -286,8 +298,11 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
                 Log_OC.d("Debug", "Failed to disable uri exposure");
             }
         }
-
-        initSyncOperations(uploadsStorageManager, accountManager, connectivityService, powerManagementService);
+        initSyncOperations(uploadsStorageManager,
+                           accountManager,
+                           connectivityService,
+                           powerManagementService,
+                           backgroundJobManager);
         initContactsBackup(accountManager);
         notificationChannels();
 
@@ -444,7 +459,8 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
         final UploadsStorageManager uploadsStorageManager,
         final UserAccountManager accountManager,
         final ConnectivityService connectivityService,
-        final PowerManagementService powerManagementService
+        final PowerManagementService powerManagementService,
+        final BackgroundJobManager jobManager
     ) {
         updateToAutoUpload();
         cleanOldEntries();
@@ -462,7 +478,7 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
 
         initiateExistingAutoUploadEntries();
 
-        FilesSyncHelper.scheduleFilesSyncIfNeeded(mContext);
+        FilesSyncHelper.scheduleFilesSyncIfNeeded(mContext, jobManager);
         FilesSyncHelper.restartJobsIfNeeded(
             uploadsStorageManager,
             accountManager,

+ 4 - 1
src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java

@@ -29,6 +29,7 @@ import android.content.Intent;
 
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.device.PowerManagementService;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ConnectivityService;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.datamodel.UploadsStorageManager;
@@ -51,6 +52,7 @@ public class BootupBroadcastReceiver extends BroadcastReceiver {
     @Inject UploadsStorageManager uploadsStorageManager;
     @Inject ConnectivityService connectivityService;
     @Inject PowerManagementService powerManagementService;
+    @Inject BackgroundJobManager backgroundJobManager;
 
     /**
      * Receives broadcast intent reporting that the system was just boot up.
@@ -66,7 +68,8 @@ public class BootupBroadcastReceiver extends BroadcastReceiver {
             MainApp.initSyncOperations(uploadsStorageManager,
                                        accountManager,
                                        connectivityService,
-                                       powerManagementService);
+                                       powerManagementService,
+                                       backgroundJobManager);
             MainApp.initContactsBackup(accountManager);
         } else {
             Log_OC.d(TAG, "Getting wrong intent: " + intent.getAction());

+ 1 - 1
src/main/java/com/owncloud/android/jobs/FilesSyncJob.java

@@ -74,7 +74,7 @@ import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR;
  */
 public class FilesSyncJob extends Job {
     public static final String TAG = "FilesSyncJob";
-    static final String SKIP_CUSTOM = "skipCustom";
+    public static final String SKIP_CUSTOM = "skipCustom";
     public static final String OVERRIDE_POWER_SAVING = "overridePowerSaving";
     private static final String WAKELOCK_TAG_SEPARATION = ":";
 

+ 0 - 102
src/main/java/com/owncloud/android/jobs/NContentObserverJob.java

@@ -1,102 +0,0 @@
-/*
- * Nextcloud Android client application
- *
- * @author Mario Danic
- * Copyright (C) 2017 Mario Danic
- * Copyright (C) 2017 Nextcloud
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.owncloud.android.jobs;
-
-import android.app.job.JobParameters;
-import android.app.job.JobService;
-import android.os.Build;
-
-import com.evernote.android.job.JobRequest;
-import com.evernote.android.job.util.support.PersistableBundleCompat;
-import com.nextcloud.client.device.PowerManagementService;
-import com.nextcloud.client.preferences.AppPreferences;
-import com.owncloud.android.MainApp;
-import com.owncloud.android.datamodel.SyncedFolderProvider;
-import com.owncloud.android.utils.FilesSyncHelper;
-
-import androidx.annotation.RequiresApi;
-
-/*
-    Job that triggers new FilesSyncJob in case new photo or video were detected
-    and starts a job to find new media folders
- */
-@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
-public class NContentObserverJob extends JobService {
-
-    private PowerManagementService powerManagementService;
-    private AppPreferences preferences;
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-
-        // Temporary workaround for https://github.com/nextcloud/android/issues/4147
-        // TODO: this must be fixed properly
-        MainApp app = (MainApp) getApplication();
-        powerManagementService = app.getPowerManagementService();
-        preferences = app.getPreferences();
-    }
-
-    @Override
-    public boolean onStartJob(JobParameters params) {
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-            if (params.getJobId() == FilesSyncHelper.ContentSyncJobId && params.getTriggeredContentAuthorities()
-                    != null && params.getTriggeredContentUris() != null
-                    && params.getTriggeredContentUris().length > 0) {
-
-                checkAndStartFileSyncJob();
-
-                new JobRequest.Builder(MediaFoldersDetectionJob.TAG)
-                        .startNow()
-                        .setUpdateCurrent(false)
-                        .build()
-                        .schedule();
-
-            }
-
-            FilesSyncHelper.scheduleJobOnN();
-        }
-
-        return true;
-    }
-
-    private void checkAndStartFileSyncJob() {
-        if (!powerManagementService.isPowerSavingEnabled() &&
-                new SyncedFolderProvider(getContentResolver(), preferences).countEnabledSyncedFolders() > 0) {
-            PersistableBundleCompat persistableBundleCompat = new PersistableBundleCompat();
-            persistableBundleCompat.putBoolean(FilesSyncJob.SKIP_CUSTOM, true);
-
-            new JobRequest.Builder(FilesSyncJob.TAG)
-                    .startNow()
-                    .setExtras(persistableBundleCompat)
-                    .setUpdateCurrent(false)
-                    .build()
-                    .schedule();
-        }
-    }
-
-    @Override
-    public boolean onStopJob(JobParameters params) {
-        return false;
-    }
-}

+ 3 - 30
src/main/java/com/owncloud/android/utils/FilesSyncHelper.java

@@ -24,9 +24,6 @@
 package com.owncloud.android.utils;
 
 import android.accounts.Account;
-import android.app.job.JobInfo;
-import android.app.job.JobScheduler;
-import android.content.ComponentName;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.Cursor;
@@ -40,6 +37,7 @@ import com.evernote.android.job.JobManager;
 import com.evernote.android.job.JobRequest;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.device.PowerManagementService;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.owncloud.android.MainApp;
@@ -52,7 +50,6 @@ import com.owncloud.android.datamodel.UploadsStorageManager;
 import com.owncloud.android.db.OCUpload;
 import com.owncloud.android.files.services.FileUploader;
 import com.owncloud.android.jobs.FilesSyncJob;
-import com.owncloud.android.jobs.NContentObserverJob;
 import com.owncloud.android.jobs.OfflineSyncJob;
 
 import org.lukhnos.nnio.file.FileVisitResult;
@@ -259,7 +256,7 @@ public final class FilesSyncHelper {
         }).start();
     }
 
-    public static void scheduleFilesSyncIfNeeded(Context context) {
+    public static void scheduleFilesSyncIfNeeded(Context context, BackgroundJobManager jobManager) {
         // always run this because it also allows us to perform retries of manual uploads
         new JobRequest.Builder(FilesSyncJob.TAG)
                 .setPeriodic(900000L, 300000L)
@@ -268,7 +265,7 @@ public final class FilesSyncHelper {
                 .schedule();
 
         if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-            scheduleJobOnN();
+            jobManager.scheduleContentObserverJob();
         }
     }
 
@@ -282,29 +279,5 @@ public final class FilesSyncHelper {
                 .schedule();
         }
     }
-
-    @RequiresApi(api = Build.VERSION_CODES.N)
-    public static void scheduleJobOnN() {
-        JobScheduler jobScheduler = MainApp.getAppContext().getSystemService(JobScheduler.class);
-
-        if (jobScheduler != null) {
-            JobInfo.Builder builder = new JobInfo.Builder(ContentSyncJobId, new ComponentName(MainApp.getAppContext(),
-                    NContentObserverJob.class.getName()));
-            builder.addTriggerContentUri(new JobInfo.TriggerContentUri(android.provider.MediaStore.
-                    Images.Media.INTERNAL_CONTENT_URI,
-                    JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));
-            builder.addTriggerContentUri(new JobInfo.TriggerContentUri(MediaStore.
-                    Images.Media.EXTERNAL_CONTENT_URI,
-                    JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));
-            builder.addTriggerContentUri(new JobInfo.TriggerContentUri(android.provider.MediaStore.
-                    Video.Media.INTERNAL_CONTENT_URI,
-                    JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));
-            builder.addTriggerContentUri(new JobInfo.TriggerContentUri(MediaStore.
-                    Video.Media.EXTERNAL_CONTENT_URI,
-                    JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));
-            builder.setTriggerContentMaxDelay(1500);
-            jobScheduler.schedule(builder.build());
-        }
-    }
 }
 

+ 87 - 0
src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt

@@ -0,0 +1,87 @@
+package com.nextcloud.client.jobs
+
+import android.content.ContentResolver
+import android.content.Context
+import android.os.Build
+import androidx.work.WorkerParameters
+import com.nextcloud.client.device.DeviceInfo
+import com.nextcloud.client.device.PowerManagementService
+import com.nextcloud.client.preferences.AppPreferences
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import javax.inject.Provider
+
+class BackgroundJobFactoryTest {
+
+    @Mock
+    private lateinit var context: Context
+
+    @Mock
+    private lateinit var params: WorkerParameters
+
+    @Mock
+    private lateinit var contentResolver: ContentResolver
+
+    @Mock
+    private lateinit var preferences: AppPreferences
+
+    @Mock
+    private lateinit var powerManagementService: PowerManagementService
+
+    @Mock
+    private lateinit var backgroundJobManager: BackgroundJobManager
+
+    @Mock
+    private lateinit var deviceInfo: DeviceInfo
+
+    private lateinit var factory: BackgroundJobFactory
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        factory = BackgroundJobFactory(
+            preferences,
+            contentResolver,
+            powerManagementService,
+            Provider { backgroundJobManager },
+            deviceInfo
+        )
+    }
+
+    @Test
+    fun `worker is created on api level 24+`() {
+        // GIVEN
+        //      api level is > 24
+        //      content URI trigger is supported
+        whenever(deviceInfo.apiLevel).thenReturn(Build.VERSION_CODES.N)
+
+        // WHEN
+        //      factory is called to create content observer worker
+        val worker = factory.createWorker(context, ContentObserverWork::class.java.name, params)
+
+        // THEN
+        //      factory creates a worker compatible with API level
+        assertNotNull(worker)
+    }
+
+    @Test
+    fun `worker is not created below api level 24`() {
+        // GIVEN
+        //      api level is < 24
+        //      content URI trigger is not supported
+        whenever(deviceInfo.apiLevel).thenReturn(Build.VERSION_CODES.M)
+
+        // WHEN
+        //      factory is called to create content observer worker
+        val worker = factory.createWorker(context, ContentObserverWork::class.java.name, params)
+
+        // THEN
+        //      factory does not create a worker incompatible with API level
+        assertNull(worker)
+    }
+}

+ 118 - 0
src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt

@@ -0,0 +1,118 @@
+package com.nextcloud.client.jobs
+
+import android.content.Context
+import android.net.Uri
+import androidx.work.WorkerParameters
+import com.nextcloud.client.device.PowerManagementService
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.MockitoAnnotations
+
+class ContentObserverWorkTest {
+
+    private lateinit var worker: ContentObserverWork
+
+    @Mock
+    lateinit var params: WorkerParameters
+
+    @Mock
+    lateinit var context: Context
+
+    @Mock
+    lateinit var folderProvider: SyncedFolderProvider
+
+    @Mock
+    lateinit var powerManagementService: PowerManagementService
+
+    @Mock
+    lateinit var backgroundJobManager: BackgroundJobManager
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        worker = ContentObserverWork(
+            appContext = context,
+            params = params,
+            syncerFolderProvider = folderProvider,
+            powerManagementService = powerManagementService,
+            backgroundJobManager = backgroundJobManager
+        )
+        val uri: Uri = Mockito.mock(Uri::class.java)
+        whenever(params.triggeredContentUris).thenReturn(listOf(uri))
+    }
+
+    @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()
+    }
+
+    @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
+    }
+
+    @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)
+    }
+
+    @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)
+    }
+}