Bladeren bron

Merge pull request #13884 from nextcloud/improve-two-way-sync-behaviour

Improve Two Way Sync Behaviour
Tobias Kaminsky 9 maanden geleden
bovenliggende
commit
54592b2813

+ 2 - 1
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

@@ -297,7 +297,8 @@ class BackgroundJobFactory @Inject constructor(
             params,
             accountManager,
             powerManagementService,
-            connectivityService
+            connectivityService,
+            preferences
         )
     }
 }

+ 2 - 1
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

@@ -172,5 +172,6 @@ interface BackgroundJobManager {
     fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean
     fun startOfflineOperations()
     fun startPeriodicallyOfflineOperation()
-    fun scheduleInternal2WaySync()
+    fun scheduleInternal2WaySync(intervalMinutes: Long)
+    fun cancelInternal2WaySyncJob()
 }

+ 8 - 3
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt

@@ -702,12 +702,17 @@ internal class BackgroundJobManagerImpl(
         )
     }
 
-    override fun scheduleInternal2WaySync() {
+    override fun scheduleInternal2WaySync(intervalMinutes: Long) {
         val request = periodicRequestBuilder(
             jobClass = InternalTwoWaySyncWork::class,
-            jobName = JOB_INTERNAL_TWO_WAY_SYNC
+            jobName = JOB_INTERNAL_TWO_WAY_SYNC,
+            intervalMins = intervalMinutes
         ).build()
 
-        workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.KEEP, request)
+        workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
+    }
+
+    override fun cancelInternal2WaySyncJob() {
+        workManager.cancelJob(JOB_INTERNAL_TWO_WAY_SYNC)
     }
 }

+ 7 - 10
app/src/main/java/com/nextcloud/client/jobs/InternalTwoWaySyncWork.kt

@@ -13,6 +13,7 @@ import androidx.work.WorkerParameters
 import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.device.PowerManagementService
 import com.nextcloud.client.network.ConnectivityService
+import com.nextcloud.client.preferences.AppPreferences
 import com.owncloud.android.MainApp
 import com.owncloud.android.datamodel.FileDataStorageManager
 import com.owncloud.android.datamodel.OCFile
@@ -21,13 +22,14 @@ import com.owncloud.android.operations.SynchronizeFolderOperation
 import com.owncloud.android.utils.FileStorageUtils
 import java.io.File
 
-@Suppress("Detekt.NestedBlockDepth", "ReturnCount")
+@Suppress("Detekt.NestedBlockDepth", "ReturnCount", "LongParameterList")
 class InternalTwoWaySyncWork(
     private val context: Context,
     params: WorkerParameters,
     private val userAccountManager: UserAccountManager,
     private val powerManagementService: PowerManagementService,
-    private val connectivityService: ConnectivityService
+    private val connectivityService: ConnectivityService,
+    private val appPreferences: AppPreferences
 ) : Worker(context, params) {
     private var shouldRun = true
 
@@ -36,7 +38,9 @@ class InternalTwoWaySyncWork(
 
         var result = true
 
-        if (powerManagementService.isPowerSavingEnabled ||
+        @Suppress("ComplexCondition")
+        if (!appPreferences.isTwoWaySyncEnabled ||
+            powerManagementService.isPowerSavingEnabled ||
             !connectivityService.isConnected ||
             connectivityService.isInternetWalled ||
             !connectivityService.connectivity.isWifi
@@ -61,13 +65,6 @@ class InternalTwoWaySyncWork(
                     return checkFreeSpaceResult
                 }
 
-                // do not attempt to sync root folder
-                if (folder.remotePath == OCFile.ROOT_PATH) {
-                    folder.internalFolderSyncTimestamp = -1L
-                    fileDataStorageManager.saveFile(folder)
-                    continue
-                }
-
                 Log_OC.d(TAG, "Folder ${folder.remotePath}: started!")
                 val operation = SynchronizeFolderOperation(context, folder.remotePath, user, fileDataStorageManager)
                     .execute(context)

+ 6 - 0
app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java

@@ -391,4 +391,10 @@ public interface AppPreferences {
 
     @NonNull
     String getLastSelectedMediaFolder();
+
+    void setTwoWaySyncStatus(boolean value);
+    boolean isTwoWaySyncEnabled();
+
+    void setTwoWaySyncInterval(Long value);
+    Long getTwoWaySyncInterval();
 }

+ 23 - 0
app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java

@@ -102,6 +102,9 @@ public final class AppPreferencesImpl implements AppPreferences {
     private static final String PREF__STORAGE_PERMISSION_REQUESTED = "storage_permission_requested";
     private static final String PREF__IN_APP_REVIEW_DATA = "in_app_review_data";
 
+    private static final String PREF__TWO_WAY_STATUS = "two_way_sync_status";
+    private static final String PREF__TWO_WAY_SYNC_INTERVAL = "two_way_sync_interval";
+
     private static final String LOG_ENTRY = "log_entry";
 
     private final Context context;
@@ -789,4 +792,24 @@ public final class AppPreferencesImpl implements AppPreferences {
     public String getLastSelectedMediaFolder() {
         return preferences.getString(PREF__MEDIA_FOLDER_LAST_PATH, OCFile.ROOT_PATH);
     }
+
+    @Override
+    public void setTwoWaySyncStatus(boolean value) {
+        preferences.edit().putBoolean(PREF__TWO_WAY_STATUS, value).apply();
+    }
+
+    @Override
+    public boolean isTwoWaySyncEnabled() {
+        return preferences.getBoolean(PREF__TWO_WAY_STATUS, true);
+    }
+
+    @Override
+    public void setTwoWaySyncInterval(Long value) {
+        preferences.edit().putLong(PREF__TWO_WAY_SYNC_INTERVAL, value).apply();
+    }
+
+    @Override
+    public Long getTwoWaySyncInterval() {
+        return preferences.getLong(PREF__TWO_WAY_SYNC_INTERVAL, 15L);
+    }
 }

+ 5 - 0
app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt

@@ -17,8 +17,13 @@ import android.os.Handler
 import android.os.Looper
 import android.widget.Toast
 import com.google.common.io.Resources
+import com.owncloud.android.R
 import com.owncloud.android.datamodel.ReceiverFlag
 
+fun Context.hourPlural(hour: Int): String = resources.getQuantityString(R.plurals.hours, hour, hour)
+
+fun Context.minPlural(min: Int): String = resources.getQuantityString(R.plurals.minutes, min, min)
+
 @SuppressLint("UnspecifiedRegisterReceiverFlag")
 fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver?, filter: IntentFilter, flag: ReceiverFlag): Intent? {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

+ 5 - 1
app/src/main/java/com/owncloud/android/MainApp.java

@@ -375,7 +375,11 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC
             backgroundJobManager.scheduleMediaFoldersDetectionJob();
             backgroundJobManager.startMediaFoldersDetectionJob();
             backgroundJobManager.schedulePeriodicHealthStatus();
-            backgroundJobManager.scheduleInternal2WaySync();
+
+            if (preferences.isTwoWaySyncEnabled()) {
+                backgroundJobManager.scheduleInternal2WaySync(preferences.getTwoWaySyncInterval());
+            }
+
             backgroundJobManager.startPeriodicallyOfflineOperation();
         }
 

+ 4 - 1
app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

@@ -2643,7 +2643,10 @@ public class FileDataStorageManager {
         List<OCFile> files = new ArrayList<>(fileEntities.size());
 
         for (FileEntity fileEntity : fileEntities) {
-            files.add(createFileInstance(fileEntity));
+            OCFile file = createFileInstance(fileEntity);
+            if (file.isFolder() && !file.isRootDirectory()) {
+                files.add(file);
+            }
         }
 
         return files;

+ 139 - 40
app/src/main/java/com/owncloud/android/ui/activity/InternalTwoWaySyncActivity.kt

@@ -10,44 +10,58 @@ package com.owncloud.android.ui.activity
 import android.annotation.SuppressLint
 import android.os.Bundle
 import android.view.Menu
-import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
-import androidx.core.view.MenuProvider
+import android.widget.ArrayAdapter
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.LinearLayoutManager
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.client.di.Injectable
 import com.nextcloud.client.jobs.BackgroundJobManager
 import com.nextcloud.client.jobs.download.FileDownloadWorker
+import com.nextcloud.utils.extensions.hourPlural
+import com.nextcloud.utils.extensions.minPlural
+import com.nextcloud.utils.extensions.setVisibleIf
 import com.owncloud.android.R
 import com.owncloud.android.databinding.InternalTwoWaySyncLayoutBinding
+import com.owncloud.android.lib.common.utils.Log_OC
 import com.owncloud.android.ui.adapter.InternalTwoWaySyncAdapter
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+
+class InternalTwoWaySyncActivity :
+    DrawerActivity(),
+    Injectable,
+    InternalTwoWaySyncAdapter.InternalTwoWaySyncAdapterOnUpdate {
+    private val tag = "InternalTwoWaySyncActivity"
 
-class InternalTwoWaySyncActivity : DrawerActivity(), Injectable {
     @Inject
     lateinit var backgroundJobManager: BackgroundJobManager
 
     lateinit var binding: InternalTwoWaySyncLayoutBinding
 
     private lateinit var internalTwoWaySyncAdapter: InternalTwoWaySyncAdapter
+    private var disableForAllFoldersMenuButton: MenuItem? = null
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
-        internalTwoWaySyncAdapter = InternalTwoWaySyncAdapter(fileDataStorageManager, user.get(), this)
+        internalTwoWaySyncAdapter = InternalTwoWaySyncAdapter(fileDataStorageManager, user.get(), this, this)
 
         binding = InternalTwoWaySyncLayoutBinding.inflate(layoutInflater)
         setContentView(binding.root)
 
         setupToolbar()
         setupActionBar()
-        setupMenuProvider()
         setupTwoWaySyncAdapter()
         setupEmptyList()
+        setupTwoWaySyncToggle()
+        setupTwoWaySyncInterval()
+        checkLayoutVisibilities(preferences.isTwoWaySyncEnabled)
     }
 
     private fun setupActionBar() {
@@ -57,12 +71,14 @@ class InternalTwoWaySyncActivity : DrawerActivity(), Injectable {
 
     @SuppressLint("NotifyDataSetChanged")
     private fun setupTwoWaySyncAdapter() {
-        binding.run {
-            list.run {
-                setEmptyView(emptyList.emptyListView)
-                adapter = internalTwoWaySyncAdapter
-                layoutManager = LinearLayoutManager(this@InternalTwoWaySyncActivity)
-                adapter?.notifyDataSetChanged()
+        if (preferences.isTwoWaySyncEnabled) {
+            binding.run {
+                list.run {
+                    setEmptyView(emptyList.emptyListView)
+                    adapter = internalTwoWaySyncAdapter
+                    layoutManager = LinearLayoutManager(this@InternalTwoWaySyncActivity)
+                    adapter?.notifyDataSetChanged()
+                }
             }
         }
     }
@@ -92,46 +108,129 @@ class InternalTwoWaySyncActivity : DrawerActivity(), Injectable {
         }
     }
 
+    @Suppress("TooGenericExceptionCaught")
     private fun disableTwoWaySyncAndWorkers() {
         lifecycleScope.launch(Dispatchers.IO) {
-            backgroundJobManager.cancelTwoWaySyncJob()
+            try {
+                backgroundJobManager.cancelTwoWaySyncJob()
+
+                val currentUser = user.get()
 
-            val folders = fileDataStorageManager.getInternalTwoWaySyncFolders(user.get())
-            folders.forEach { folder ->
-                FileDownloadWorker.cancelOperation(user.get().accountName, folder.fileId)
-                backgroundJobManager.cancelFilesDownloadJob(user.get(), folder.fileId)
+                val folders = fileDataStorageManager.getInternalTwoWaySyncFolders(currentUser)
+                folders.forEach { folder ->
+                    FileDownloadWorker.cancelOperation(currentUser.accountName, folder.fileId)
+                    backgroundJobManager.cancelFilesDownloadJob(currentUser, folder.fileId)
+
+                    folder.internalFolderSyncTimestamp = -1L
+                    fileDataStorageManager.saveFile(folder)
+                }
 
-                folder.internalFolderSyncTimestamp = -1L
-                fileDataStorageManager.saveFile(folder)
+                withContext(Dispatchers.Main) {
+                    internalTwoWaySyncAdapter.update()
+                }
+            } catch (e: Exception) {
+                Log_OC.d(tag, "Error caught at disableTwoWaySyncAndWorkers: $e")
             }
+        }
+    }
+
+    @Suppress("MagicNumber")
+    private fun setupTwoWaySyncInterval() {
+        val durations = listOf(
+            15.minutes to minPlural(15),
+            30.minutes to minPlural(30),
+            45.minutes to minPlural(45),
+            1.hours to hourPlural(1),
+            2.hours to hourPlural(2),
+            4.hours to hourPlural(4),
+            6.hours to hourPlural(6),
+            8.hours to hourPlural(8),
+            12.hours to hourPlural(12),
+            24.hours to hourPlural(24)
+        )
+        val selectedDuration = durations.find { it.first.inWholeMinutes == preferences.twoWaySyncInterval }
 
-            launch(Dispatchers.Main) {
-                internalTwoWaySyncAdapter.update()
+        val adapter = ArrayAdapter(
+            this,
+            android.R.layout.simple_dropdown_item_1line,
+            durations.map { it.second }
+        )
+
+        binding.twoWaySyncInterval.run {
+            setAdapter(adapter)
+            setText(selectedDuration?.second ?: minPlural(15), false)
+            setOnItemClickListener { _, _, position, _ ->
+                handleDurationSelected(durations[position].first.inWholeMinutes)
             }
         }
     }
 
-    private fun setupMenuProvider() {
-        addMenuProvider(
-            object : MenuProvider {
-                override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
-                    menuInflater.inflate(R.menu.activity_internal_two_way_sync, menu)
-                }
+    private fun handleDurationSelected(duration: Long) {
+        preferences.twoWaySyncInterval = duration
+        backgroundJobManager.scheduleInternal2WaySync(duration)
+    }
 
-                override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
-                    return when (menuItem.itemId) {
-                        android.R.id.home -> {
-                            onBackPressed()
-                            true
-                        }
-                        R.id.action_dismiss_two_way_sync -> {
-                            disableTwoWaySyncAndWorkers()
-                            true
-                        }
-                        else -> false
-                    }
-                }
+    private fun setupTwoWaySyncToggle() {
+        binding.twoWaySyncToggle.isChecked = preferences.isTwoWaySyncEnabled
+        binding.twoWaySyncToggle.setOnCheckedChangeListener { _, isChecked ->
+            preferences.setTwoWaySyncStatus(isChecked)
+            setupTwoWaySyncAdapter()
+            checkLayoutVisibilities(isChecked)
+            checkDisableForAllFoldersMenuButtonVisibility()
+
+            if (isChecked) {
+                backgroundJobManager.scheduleInternal2WaySync(preferences.twoWaySyncInterval)
+            } else {
+                backgroundJobManager.cancelInternal2WaySyncJob()
             }
-        )
+        }
+    }
+
+    private fun checkLayoutVisibilities(condition: Boolean) {
+        binding.listFrameLayout.setVisibleIf(condition)
+        binding.twoWaySyncIntervalLayout.setVisibleIf(condition)
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+        menuInflater.inflate(R.menu.activity_internal_two_way_sync, menu)
+        disableForAllFoldersMenuButton = menu?.findItem(R.id.action_dismiss_two_way_sync)
+        checkDisableForAllFoldersMenuButtonVisibility()
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        when (item.itemId) {
+            android.R.id.home -> {
+                onBackPressed()
+            }
+            R.id.action_dismiss_two_way_sync -> {
+                disableTwoWaySyncAndWorkers()
+            }
+        }
+
+        return super.onOptionsItemSelected(item)
+    }
+
+    private fun checkDisableForAllFoldersMenuButtonVisibility() {
+        lifecycleScope.launch {
+            val folderSize = withContext(Dispatchers.IO) {
+                fileDataStorageManager.getInternalTwoWaySyncFolders(user.get()).size
+            }
+
+            checkDisableForAllFoldersMenuButtonVisibility(preferences.isTwoWaySyncEnabled, folderSize)
+        }
+    }
+
+    private fun checkDisableForAllFoldersMenuButtonVisibility(isTwoWaySyncEnabled: Boolean, folderSize: Int) {
+        val showDisableButton = isTwoWaySyncEnabled && folderSize > 0
+
+        disableForAllFoldersMenuButton?.let {
+            it.setVisible(showDisableButton)
+            it.setEnabled(showDisableButton)
+        }
+    }
+
+    override fun onUpdate(folderSize: Int) {
+        checkDisableForAllFoldersMenuButtonVisibility(preferences.isTwoWaySyncEnabled, folderSize)
     }
 }

+ 3 - 4
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java

@@ -328,7 +328,7 @@ public class SettingsActivity extends PreferenceActivity
         viewThemeUtils.files.themePreferenceCategory(preferenceCategorySync);
 
         setupAutoUploadPreference(preferenceCategorySync);
-        setupInternalTwoWaySyncPreference(preferenceCategorySync);
+        setupInternalTwoWaySyncPreference();
     }
 
     private void setupMoreCategory() {
@@ -567,7 +567,7 @@ public class SettingsActivity extends PreferenceActivity
         }
     }
 
-    private void setupInternalTwoWaySyncPreference(PreferenceCategory preferenceCategorySync) {
+    private void setupInternalTwoWaySyncPreference() {
         Preference twoWaySync = findPreference("internal_two_way_sync");
 
         twoWaySync.setOnPreferenceClickListener(preference -> {
@@ -665,8 +665,7 @@ public class SettingsActivity extends PreferenceActivity
         }
     }
 
-    private void setupShowEcosystemAppsPreference(PreferenceCategory preferenceCategoryDetails,
-                                            boolean fShowEcosystemAppsEnabled) {
+    private void setupShowEcosystemAppsPreference(PreferenceCategory preferenceCategoryDetails, boolean fShowEcosystemAppsEnabled) {
         showEcosystemApps = (ThemeableSwitchPreference) findPreference("show_ecosystem_apps");
         if (fShowEcosystemAppsEnabled) {
             showEcosystemApps.setOnPreferenceClickListener(preference -> {

+ 8 - 1
app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncAdapter.kt

@@ -20,8 +20,14 @@ import com.owncloud.android.datamodel.OCFile
 class InternalTwoWaySyncAdapter(
     private val dataStorageManager: FileDataStorageManager,
     private val user: User,
-    val context: Context
+    val context: Context,
+    private val onUpdateListener: InternalTwoWaySyncAdapterOnUpdate
 ) : RecyclerView.Adapter<InternalTwoWaySyncViewHolder>() {
+
+    interface InternalTwoWaySyncAdapterOnUpdate {
+        fun onUpdate(folderSize: Int)
+    }
+
     var folders: List<OCFile> = dataStorageManager.getInternalTwoWaySyncFolders(user)
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InternalTwoWaySyncViewHolder {
@@ -46,5 +52,6 @@ class InternalTwoWaySyncAdapter(
     fun update() {
         folders = dataStorageManager.getInternalTwoWaySyncFolders(user)
         notifyDataSetChanged()
+        onUpdateListener.onUpdate(folders.size)
     }
 }

+ 46 - 8
app/src/main/res/layout/internal_two_way_sync_layout.xml

@@ -5,7 +5,8 @@
   ~ SPDX-FileCopyrightText: 2024 Tobias Kaminsky <tobias@kaminsky.me>
   ~ SPDX-License-Identifier: AGPL-3.0-or-later
   -->
-<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.drawerlayout.widget.DrawerLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/drawer_layout"
     android:layout_width="match_parent"
@@ -20,15 +21,52 @@
 
         <include layout="@layout/toolbar_standard" />
 
-        <com.owncloud.android.ui.EmptyRecyclerView
-            android:id="@+id/list"
+        <com.google.android.material.materialswitch.MaterialSwitch
+            android:id="@+id/twoWaySyncToggle"
+            android:text="@string/prefs_two_way_sync_switch_title"
+            android:textSize="@dimen/txt_size_16sp"
+            android:minHeight="48dp"
+            android:layout_marginHorizontal="@dimen/standard_half_padding"
+            android:layout_marginTop="@dimen/alternate_margin"
+            android:layout_marginBottom="@dimen/alternate_margin"
             android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:orientation="vertical" />
+            android:layout_height="wrap_content" />
 
-        <include
-            android:id="@+id/empty_list"
-            layout="@layout/empty_list" />
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/two_way_sync_interval_layout"
+            android:layout_marginHorizontal="@dimen/standard_half_padding"
+            style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="@dimen/alternate_margin"
+            android:hint="@string/prefs_two_way_sync_interval">
+
+            <AutoCompleteTextView
+                android:id="@+id/two_way_sync_interval"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:inputType="none"
+                tools:ignore="LabelFor" />
+
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <FrameLayout
+            android:id="@+id/list_frame_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <com.owncloud.android.ui.EmptyRecyclerView
+                android:id="@+id/list"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:orientation="vertical" />
+
+            <include
+                android:id="@+id/empty_list"
+                layout="@layout/empty_list" />
+
+        </FrameLayout>
 
     </LinearLayout>
+
 </androidx.drawerlayout.widget.DrawerLayout>

+ 19 - 0
app/src/main/res/values/strings.xml

@@ -115,6 +115,25 @@
     <string name="prefs_value_theme_dark">Dark</string>
     <string name="prefs_value_theme_system">Follow system</string>
     <string name="prefs_theme_title">Theme</string>
+    <string name="prefs_two_way_sync_switch_title">Enable two way sync</string>
+    <string name="prefs_two_way_sync_interval">Interval</string>
+
+    <plurals name="hours">
+        <item quantity="zero">%d hours</item>
+        <item quantity="one">%d hour</item>
+        <item quantity="two">%d hours</item>
+        <item quantity="few">%d hours</item>
+        <item quantity="many">%d hours</item>
+        <item quantity="other">%d hours</item>
+    </plurals>
+    <plurals name="minutes">
+        <item quantity="zero">%d minutes</item>
+        <item quantity="one">%d minute</item>
+        <item quantity="two">%d minutes</item>
+        <item quantity="few">%d minutes</item>
+        <item quantity="many">%d minutes</item>
+        <item quantity="other">%d minutes</item>
+    </plurals>
 
     <string name="recommend_subject">Try %1$s on your device!</string>
     <string name="recommend_text">I want to invite you to use %1$s on your device.\nDownload here: %2$s</string>