Merge remote-tracking branch 'origin/master' into dev

This commit is contained in:
Tobias Kaminsky 2024-09-05 02:31:14 +02:00
commit ec228a3179
116 changed files with 3384 additions and 499 deletions

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import android.os.NetworkOnMainThreadException;
import android.text.TextUtils;
import android.view.View;
@ -375,6 +376,11 @@ public abstract class AbstractIT {
public void uploadOCUpload(OCUpload ocUpload) {
ConnectivityService connectivityServiceMock = new ConnectivityService() {
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
return false;
}
@Override
public boolean isConnected() {
return false;

View file

@ -13,6 +13,7 @@ import android.accounts.OperationCanceledException;
import android.content.ActivityNotFoundException;
import android.net.Uri;
import android.os.Bundle;
import android.os.NetworkOnMainThreadException;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
@ -187,6 +188,11 @@ public abstract class AbstractOnServerIT extends AbstractIT {
public void uploadOCUpload(OCUpload ocUpload, int localBehaviour) {
ConnectivityService connectivityServiceMock = new ConnectivityService() {
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
return false;
}
@Override
public boolean isConnected() {
return false;

View file

@ -8,6 +8,8 @@
*/
package com.owncloud.android;
import android.os.NetworkOnMainThreadException;
import com.nextcloud.client.account.UserAccountManagerImpl;
import com.nextcloud.client.device.BatteryStatus;
import com.nextcloud.client.device.PowerManagementService;
@ -56,6 +58,11 @@ public class UploadIT extends AbstractOnServerIT {
targetContext.getContentResolver());
private ConnectivityService connectivityServiceMock = new ConnectivityService() {
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
return false;
}
@Override
public boolean isConnected() {
return false;
@ -274,6 +281,11 @@ public class UploadIT extends AbstractOnServerIT {
@Test
public void testUploadOnWifiOnlyButNoWifi() {
ConnectivityService connectivityServiceMock = new ConnectivityService() {
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
return false;
}
@Override
public boolean isConnected() {
return false;
@ -358,6 +370,11 @@ public class UploadIT extends AbstractOnServerIT {
@Test
public void testUploadOnWifiOnlyButMeteredWifi() {
ConnectivityService connectivityServiceMock = new ConnectivityService() {
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
return false;
}
@Override
public boolean isConnected() {
return false;

View file

@ -34,6 +34,10 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
private var uploadsStorageManager: UploadsStorageManager? = null
private val connectivityServiceMock: ConnectivityService = object : ConnectivityService {
override fun isNetworkAndServerAvailable(): Boolean {
return false
}
override fun isConnected(): Boolean {
return false
}

View file

@ -62,7 +62,8 @@ public class ConflictsResolveActivityIT extends AbstractIT {
ConflictsResolveActivity sut = activityRule.launchActivity(intent);
ConflictsResolveDialog dialog = ConflictsResolveDialog.newInstance(existingFile,
ConflictsResolveDialog dialog = ConflictsResolveDialog.newInstance(targetContext,
existingFile,
newFile,
UserAccountManagerImpl
.fromContext(targetContext)
@ -209,7 +210,7 @@ public class ConflictsResolveActivityIT extends AbstractIT {
getInstrumentation().waitForIdleSync();
onView(withId(R.id.existing_checkbox)).perform(click());
onView(withId(R.id.right_checkbox)).perform(click());
DialogFragment dialog = (DialogFragment) sut.getSupportFragmentManager().findFragmentByTag("conflictDialog");
screenshot(Objects.requireNonNull(dialog.requireDialog().getWindow()).getDecorView());
@ -255,7 +256,7 @@ public class ConflictsResolveActivityIT extends AbstractIT {
getInstrumentation().waitForIdleSync();
onView(withId(R.id.new_checkbox)).perform(click());
onView(withId(R.id.left_checkbox)).perform(click());
DialogFragment dialog = (DialogFragment) sut.getSupportFragmentManager().findFragmentByTag("conflictDialog");
screenshot(Objects.requireNonNull(dialog.requireDialog().getWindow()).getDecorView());
@ -300,8 +301,8 @@ public class ConflictsResolveActivityIT extends AbstractIT {
getInstrumentation().waitForIdleSync();
onView(withId(R.id.existing_checkbox)).perform(click());
onView(withId(R.id.new_checkbox)).perform(click());
onView(withId(R.id.right_checkbox)).perform(click());
onView(withId(R.id.left_checkbox)).perform(click());
DialogFragment dialog = (DialogFragment) sut.getSupportFragmentManager().findFragmentByTag("conflictDialog");
screenshot(Objects.requireNonNull(dialog.requireDialog().getWindow()).getDecorView());

View file

@ -53,6 +53,10 @@ class TestActivity :
override fun getConnectivity(): Connectivity {
return Connectivity.CONNECTED_WIFI
}
override fun isNetworkAndServerAvailable(): Boolean {
return false
}
}
override fun onCreate(savedInstanceState: Bundle?) {

View file

@ -266,6 +266,9 @@
</intent-filter>
</activity>
<receiver
android:name="com.nextcloud.receiver.OfflineOperationActionReceiver"
android:exported="false" />
<receiver
android:name="com.nextcloud.client.jobs.MediaFoldersDetectionWork$NotificationReceiver"
android:exported="false" />

View file

@ -11,6 +11,7 @@ import android.content.Context
import com.nextcloud.client.core.Clock
import com.nextcloud.client.database.dao.ArbitraryDataDao
import com.nextcloud.client.database.dao.FileDao
import com.nextcloud.client.database.dao.OfflineOperationDao
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@ -33,4 +34,9 @@ class DatabaseModule {
fun fileDao(nextcloudDatabase: NextcloudDatabase): FileDao {
return nextcloudDatabase.fileDao()
}
@Provides
fun offlineOperationsDao(nextcloudDatabase: NextcloudDatabase): OfflineOperationDao {
return nextcloudDatabase.offlineOperationDao()
}
}

View file

@ -16,11 +16,13 @@ import com.nextcloud.client.core.Clock
import com.nextcloud.client.core.ClockImpl
import com.nextcloud.client.database.dao.ArbitraryDataDao
import com.nextcloud.client.database.dao.FileDao
import com.nextcloud.client.database.dao.OfflineOperationDao
import com.nextcloud.client.database.entity.ArbitraryDataEntity
import com.nextcloud.client.database.entity.CapabilityEntity
import com.nextcloud.client.database.entity.ExternalLinkEntity
import com.nextcloud.client.database.entity.FileEntity
import com.nextcloud.client.database.entity.FilesystemEntity
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.database.entity.ShareEntity
import com.nextcloud.client.database.entity.SyncedFolderEntity
import com.nextcloud.client.database.entity.UploadEntity
@ -41,7 +43,8 @@ import com.owncloud.android.db.ProviderMeta
ShareEntity::class,
SyncedFolderEntity::class,
UploadEntity::class,
VirtualEntity::class
VirtualEntity::class,
OfflineOperationEntity::class
],
version = ProviderMeta.DB_VERSION,
autoMigrations = [
@ -61,7 +64,8 @@ import com.owncloud.android.db.ProviderMeta
AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81),
AutoMigration(from = 81, to = 82),
AutoMigration(from = 82, to = 83)
AutoMigration(from = 82, to = 83),
AutoMigration(from = 83, to = 84)
],
exportSchema = true
)
@ -70,6 +74,7 @@ abstract class NextcloudDatabase : RoomDatabase() {
abstract fun arbitraryDataDao(): ArbitraryDataDao
abstract fun fileDao(): FileDao
abstract fun offlineOperationDao(): OfflineOperationDao
companion object {
const val FIRST_ROOM_DB_VERSION = 65

View file

@ -0,0 +1,39 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.nextcloud.client.database.entity.OfflineOperationEntity
@Dao
interface OfflineOperationDao {
@Query("SELECT * FROM offline_operations")
fun getAll(): List<OfflineOperationEntity>
@Insert
fun insert(vararg entity: OfflineOperationEntity)
@Update
fun update(entity: OfflineOperationEntity)
@Delete
fun delete(entity: OfflineOperationEntity)
@Query("DELETE FROM offline_operations WHERE offline_operations_path = :path")
fun deleteByPath(path: String)
@Query("SELECT * FROM offline_operations WHERE offline_operations_path = :path LIMIT 1")
fun getByPath(path: String): OfflineOperationEntity?
@Query("SELECT * FROM offline_operations WHERE offline_operations_parent_oc_file_id = :parentOCFileId")
fun getSubDirectoriesByParentOCFileId(parentOCFileId: Long): List<OfflineOperationEntity>
}

View file

@ -0,0 +1,39 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.nextcloud.model.OfflineOperationType
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
@Entity(tableName = ProviderTableMeta.OFFLINE_OPERATION_TABLE_NAME)
data class OfflineOperationEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = ProviderTableMeta._ID)
val id: Int? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_PARENT_OC_FILE_ID)
var parentOCFileId: Long? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_PARENT_PATH)
var parentPath: String? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_TYPE)
var type: OfflineOperationType? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_PATH)
var path: String? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_FILE_NAME)
var filename: String? = null,
@ColumnInfo(name = ProviderTableMeta.OFFLINE_OPERATION_CREATED_AT)
var createdAt: Long? = null
)

View file

@ -28,6 +28,7 @@ import com.nextcloud.client.core.ClockImpl;
import com.nextcloud.client.core.ThreadPoolAsyncRunner;
import com.nextcloud.client.database.dao.ArbitraryDataDao;
import com.nextcloud.client.device.DeviceInfo;
import com.nextcloud.client.jobs.operation.FileOperationHelper;
import com.nextcloud.client.logger.FileLogHandler;
import com.nextcloud.client.logger.Logger;
import com.nextcloud.client.logger.LoggerImpl;
@ -250,6 +251,11 @@ class AppModule {
return new PassCodeManager(preferences, clock);
}
@Provides
FileOperationHelper fileOperationHelper(CurrentAccountProvider currentAccountProvider, Context context) {
return new FileOperationHelper(currentAccountProvider.getUser(), context, fileDataStorageManager(currentAccountProvider, context));
}
@Provides
@Singleton
UsersAndGroupsSearchConfig userAndGroupSearchConfig() {

View file

@ -24,6 +24,7 @@ import com.nextcloud.client.onboarding.WhatsNewActivity;
import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity;
import com.nextcloud.client.widget.DashboardWidgetProvider;
import com.nextcloud.client.widget.DashboardWidgetService;
import com.nextcloud.receiver.NetworkChangeReceiver;
import com.nextcloud.ui.ChooseAccountDialogFragment;
import com.nextcloud.ui.ImageDetailFragment;
import com.nextcloud.ui.SetStatusDialogFragment;
@ -313,6 +314,9 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract BootupBroadcastReceiver bootupBroadcastReceiver();
@ContributesAndroidInjector
abstract NetworkChangeReceiver networkChangeReceiver();
@ContributesAndroidInjector
abstract NotificationWork.NotificationReceiver notificationWorkBroadcastReceiver();

View file

@ -23,6 +23,7 @@ import com.nextcloud.client.documentscan.GeneratePDFUseCase
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.integrations.deck.DeckApi
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
@ -95,12 +96,17 @@ class BackgroundJobFactory @Inject constructor(
GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
HealthStatusWork::class -> createHealthStatusWork(context, workerParameters)
TestJob::class -> createTestJob(context, workerParameters)
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
else -> null // caller falls back to default factory
}
}
}
private fun createOfflineOperationsWorker(context: Context, params: WorkerParameters): ListenableWorker {
return OfflineOperationsWorker(accountManager.user, context, connectivityService, viewThemeUtils.get(), params)
}
private fun createFilesExportWork(context: Context, params: WorkerParameters): ListenableWorker {
return FilesExportWork(
context,

View file

@ -168,5 +168,7 @@ interface BackgroundJobManager {
fun schedulePeriodicHealthStatus()
fun startHealthStatus()
fun bothFilesSyncJobsRunning(syncedFolderID: Long): Boolean
fun startOfflineOperations()
fun startPeriodicallyOfflineOperation()
fun scheduleInternal2WaySync()
}

View file

@ -26,6 +26,7 @@ import com.nextcloud.client.core.Clock
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.extensions.isWorkRunning
@ -80,7 +81,8 @@ internal class BackgroundJobManagerImpl(
const val JOB_PDF_GENERATION = "pdf_generation"
const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
const val JOB_IMMEDIATE_FILES_EXPORT = "immediate_files_export"
const val JOB_OFFLINE_OPERATIONS = "offline_operations"
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status"
@ -98,6 +100,7 @@ internal class BackgroundJobManagerImpl(
const val NOT_SET_VALUE = "not set"
const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L
const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L
const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L
@ -198,13 +201,15 @@ internal class BackgroundJobManagerImpl(
private fun oneTimeRequestBuilder(
jobClass: KClass<out ListenableWorker>,
jobName: String,
user: User? = null
user: User? = null,
constraints: Constraints = Constraints.Builder().build()
): OneTimeWorkRequest.Builder {
val builder = OneTimeWorkRequest.Builder(jobClass.java)
.addTag(TAG_ALL)
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
.setConstraints(constraints)
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
@ -217,7 +222,8 @@ internal class BackgroundJobManagerImpl(
jobName: String,
intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
flexIntervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
user: User? = null
user: User? = null,
constraints: Constraints = Constraints.Builder().build()
): PeriodicWorkRequest.Builder {
val builder = PeriodicWorkRequest.Builder(
jobClass.java,
@ -230,6 +236,7 @@ internal class BackgroundJobManagerImpl(
.addTag(formatNameTag(jobName, user))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(jobClass))
.setConstraints(constraints)
user?.let { builder.addTag(formatUserTag(it)) }
return builder
}
@ -411,6 +418,47 @@ internal class BackgroundJobManagerImpl(
workManager.isWorkRunning(JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID)
}
override fun startPeriodicallyOfflineOperation() {
val inputData = Data.Builder()
.putString(OfflineOperationsWorker.JOB_NAME, JOB_PERIODIC_OFFLINE_OPERATIONS)
.build()
val request = periodicRequestBuilder(
jobClass = OfflineOperationsWorker::class,
jobName = JOB_PERIODIC_OFFLINE_OPERATIONS,
intervalMins = OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES
)
.setInputData(inputData)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_OFFLINE_OPERATIONS,
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
override fun startOfflineOperations() {
val inputData = Data.Builder()
.putString(OfflineOperationsWorker.JOB_NAME, JOB_OFFLINE_OPERATIONS)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
oneTimeRequestBuilder(OfflineOperationsWorker::class, JOB_OFFLINE_OPERATIONS, constraints = constraints)
.setInputData(inputData)
.build()
workManager.enqueueUniqueWork(
JOB_OFFLINE_OPERATIONS,
ExistingWorkPolicy.REPLACE,
request
)
}
override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) {
val arguments = Data.Builder()
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)

View file

@ -30,12 +30,16 @@ open class WorkerNotificationManager(
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var notificationBuilder: NotificationCompat.Builder =
NotificationUtils.newNotificationBuilder(context, "WorkerNotificationManager", viewThemeUtils).apply {
NotificationUtils.newNotificationBuilder(
context,
NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS,
viewThemeUtils
).apply {
setTicker(context.getString(tickerId))
setSmallIcon(R.drawable.notification_icon)
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon))
setStyle(NotificationCompat.BigTextStyle())
setPriority(NotificationCompat.PRIORITY_LOW)
priority = NotificationCompat.PRIORITY_LOW
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD)

View file

@ -0,0 +1,135 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.offlineOperations
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.receiver.OfflineOperationActionReceiver
import com.nextcloud.utils.extensions.getErrorMessage
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.ui.activity.ConflictsResolveActivity
import com.owncloud.android.utils.theme.ViewThemeUtils
class OfflineOperationsNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) :
WorkerNotificationManager(
ID,
context,
viewThemeUtils,
R.string.offline_operations_worker_notification_manager_ticker
) {
companion object {
private const val ID = 121
private const val ERROR_ID = 122
}
@Suppress("MagicNumber")
fun start() {
notificationBuilder.run {
setContentTitle(context.getString(R.string.offline_operations_worker_notification_start_text))
setProgress(100, 0, false)
}
showNotification()
}
@Suppress("MagicNumber")
fun update(totalOperationSize: Int, currentOperationIndex: Int, filename: String) {
val title = if (totalOperationSize > 1) {
String.format(
context.getString(R.string.offline_operations_worker_progress_text),
currentOperationIndex,
totalOperationSize,
filename
)
} else {
filename
}
val progress = (currentOperationIndex * 100) / totalOperationSize
notificationBuilder.run {
setContentTitle(title)
setProgress(100, progress, false)
}
showNotification()
}
fun showNewNotification(result: RemoteOperationResult<*>, operation: RemoteOperation<*>) {
val reason = (result to operation).getErrorMessage()
val text = context.getString(R.string.offline_operations_worker_notification_error_text, reason)
notificationBuilder.run {
setContentTitle(text)
setOngoing(false)
notificationManager.notify(ERROR_ID, this.build())
}
}
fun showConflictResolveNotification(file: OCFile, entity: OfflineOperationEntity?, user: User) {
val path = entity?.path
val id = entity?.id
if (path == null || id == null) {
return
}
val resolveConflictIntent = ConflictsResolveActivity.createIntent(file, path, context)
val resolveConflictPendingIntent = PendingIntent.getActivity(
context,
id,
resolveConflictIntent,
PendingIntent.FLAG_IMMUTABLE
)
val resolveConflictAction = NotificationCompat.Action(
R.drawable.ic_cloud_upload,
context.getString(R.string.upload_list_resolve_conflict),
resolveConflictPendingIntent
)
val deleteIntent = Intent(context, OfflineOperationActionReceiver::class.java).apply {
putExtra(OfflineOperationActionReceiver.FILE_PATH, path)
putExtra(OfflineOperationActionReceiver.USER, user)
}
val deletePendingIntent =
PendingIntent.getBroadcast(context, 0, deleteIntent, PendingIntent.FLAG_IMMUTABLE)
val deleteAction = NotificationCompat.Action(
R.drawable.ic_delete,
context.getString(R.string.offline_operations_worker_notification_delete_offline_folder),
deletePendingIntent
)
val title = context.getString(
R.string.offline_operations_worker_notification_conflict_text,
file.fileName
)
notificationBuilder
.clearActions()
.setContentTitle(title)
.setContentIntent(resolveConflictPendingIntent)
.addAction(deleteAction)
.addAction(resolveConflictAction)
notificationManager.notify(id, notificationBuilder.build())
}
fun dismissNotification(id: Int?) {
if (id == null) return
notificationManager.cancel(id)
}
}

View file

@ -0,0 +1,161 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.offlineOperations
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository
import com.nextcloud.client.network.ClientFactoryImpl
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.model.OfflineOperationType
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.CreateFolderOperation
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
class OfflineOperationsWorker(
private val user: User,
private val context: Context,
private val connectivityService: ConnectivityService,
viewThemeUtils: ViewThemeUtils,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private val TAG = OfflineOperationsWorker::class.java.simpleName
const val JOB_NAME = "JOB_NAME"
}
private val fileDataStorageManager = FileDataStorageManager(user, context.contentResolver)
private val clientFactory = ClientFactoryImpl(context)
private val notificationManager = OfflineOperationsNotificationManager(context, viewThemeUtils)
private var repository = OfflineOperationsRepository(fileDataStorageManager)
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = coroutineScope {
val jobName = inputData.getString(JOB_NAME)
Log_OC.d(
TAG,
"$jobName -----------------------------------\n" +
"OfflineOperationsWorker started" +
"\n-----------------------------------"
)
if (!connectivityService.isNetworkAndServerAvailable()) {
Log_OC.d(TAG, "OfflineOperationsWorker cancelled, no internet connection")
return@coroutineScope Result.retry()
}
val client = clientFactory.create(user)
notificationManager.start()
var operations = fileDataStorageManager.offlineOperationDao.getAll()
val totalOperations = operations.size
var currentSuccessfulOperationIndex = 0
return@coroutineScope try {
while (operations.isNotEmpty()) {
val operation = operations.first()
val result = executeOperation(operation, client)
val isSuccess = handleResult(
operation,
totalOperations,
currentSuccessfulOperationIndex,
result?.first,
result?.second
)
operations = if (isSuccess) {
currentSuccessfulOperationIndex++
fileDataStorageManager.offlineOperationDao.getAll()
} else {
operations.filter { it != operation }
}
}
Log_OC.d(TAG, "OfflineOperationsWorker successfully completed")
notificationManager.dismissNotification()
WorkerStateLiveData.instance().setWorkState(WorkerState.OfflineOperationsCompleted)
Result.success()
} catch (e: Exception) {
Log_OC.d(TAG, "OfflineOperationsWorker terminated: $e")
Result.failure()
}
}
@Suppress("Deprecation")
private suspend fun executeOperation(
operation: OfflineOperationEntity,
client: OwnCloudClient
): Pair<RemoteOperationResult<*>?, RemoteOperation<*>?>? {
return when (operation.type) {
OfflineOperationType.CreateFolder -> {
if (operation.parentPath != null) {
val createFolderOperation = withContext(Dispatchers.IO) {
CreateFolderOperation(
operation.path,
user,
context,
fileDataStorageManager
)
}
createFolderOperation.execute(client) to createFolderOperation
} else {
Log_OC.d(TAG, "CreateFolder operation incomplete, missing parentPath")
null
}
}
else -> {
Log_OC.d(TAG, "Unsupported operation type: ${operation.type}")
null
}
}
}
private fun handleResult(
operation: OfflineOperationEntity,
totalOperations: Int,
currentSuccessfulOperationIndex: Int,
result: RemoteOperationResult<*>?,
remoteOperation: RemoteOperation<*>?
): Boolean {
if (result == null) {
Log_OC.d(TAG, "Operation not completed, result is null")
return false
}
val logMessage = if (result.isSuccess) "Operation completed" else "Operation failed"
Log_OC.d(TAG, "$logMessage path: ${operation.path}, type: ${operation.type}")
if (result.isSuccess) {
repository.updateNextOperations(operation)
fileDataStorageManager.offlineOperationDao.delete(operation)
notificationManager.update(totalOperations, currentSuccessfulOperationIndex, operation.filename ?: "")
} else {
val excludedErrorCodes = listOf(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS)
if (remoteOperation != null && !excludedErrorCodes.contains(result.code)) {
notificationManager.showNewNotification(result, remoteOperation)
}
}
return result.isSuccess
}
}

View file

@ -0,0 +1,91 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.offlineOperations.repository
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
class OfflineOperationsRepository(
private val fileDataStorageManager: FileDataStorageManager
) : OfflineOperationsRepositoryType {
private val dao = fileDataStorageManager.offlineOperationDao
private val pathSeparator = '/'
@Suppress("NestedBlockDepth")
override fun getAllSubdirectories(fileId: Long): List<OfflineOperationEntity> {
val result = mutableListOf<OfflineOperationEntity>()
val queue = ArrayDeque<Long>()
queue.add(fileId)
val processedIds = mutableSetOf<Long>()
while (queue.isNotEmpty()) {
val currentFileId = queue.removeFirst()
if (currentFileId in processedIds || currentFileId == 1L) continue
processedIds.add(currentFileId)
val subDirectories = dao.getSubDirectoriesByParentOCFileId(currentFileId)
result.addAll(subDirectories)
subDirectories.forEach {
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(it.path)
ocFile?.fileId?.let { newFileId ->
if (newFileId != 1L && newFileId !in processedIds) {
queue.add(newFileId)
}
}
}
}
return result
}
override fun deleteOperation(file: OCFile) {
getAllSubdirectories(file.fileId).forEach {
dao.delete(it)
}
file.decryptedRemotePath?.let {
val entity = dao.getByPath(it)
entity?.let {
dao.delete(entity)
}
}
fileDataStorageManager.removeFile(file, true, true)
}
override fun updateNextOperations(operation: OfflineOperationEntity) {
val ocFile = fileDataStorageManager.getFileByDecryptedRemotePath(operation.path)
val fileId = ocFile?.fileId ?: return
getAllSubdirectories(fileId)
.mapNotNull { nextOperation ->
nextOperation.parentOCFileId?.let { parentId ->
fileDataStorageManager.getFileById(parentId)?.let { ocFile ->
ocFile.decryptedRemotePath?.let { updatedPath ->
val newParentPath = ocFile.parentRemotePath
val newPath = updatedPath + nextOperation.filename + pathSeparator
if (newParentPath != nextOperation.parentPath || newPath != nextOperation.path) {
nextOperation.apply {
parentPath = newParentPath
path = newPath
}
} else {
null
}
}
}
}
}
.forEach { dao.update(it) }
}
}

View file

@ -0,0 +1,17 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.offlineOperations.repository
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.owncloud.android.datamodel.OCFile
interface OfflineOperationsRepositoryType {
fun getAllSubdirectories(fileId: Long): List<OfflineOperationEntity>
fun deleteOperation(file: OCFile)
fun updateNextOperations(operation: OfflineOperationEntity)
}

View file

@ -0,0 +1,65 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.operation
import android.content.Context
import com.nextcloud.client.account.User
import com.nextcloud.client.network.ClientFactoryImpl
import com.nextcloud.utils.extensions.getErrorMessage
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.RemoveFileOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
class FileOperationHelper(
private val user: User,
private val context: Context,
private val fileDataStorageManager: FileDataStorageManager
) {
companion object {
private val TAG = FileOperationHelper::class.java.simpleName
}
private val clientFactory = ClientFactoryImpl(context)
private val client = clientFactory.create(user)
@Suppress("TooGenericExceptionCaught", "Deprecation")
suspend fun removeFile(file: OCFile, onlyLocalCopy: Boolean, inBackground: Boolean): Boolean {
return withContext(Dispatchers.IO) {
try {
val operation = async {
RemoveFileOperation(
file,
onlyLocalCopy,
user,
inBackground,
context,
fileDataStorageManager
)
}
val operationResult = operation.await()
val result = operationResult.execute(client)
return@withContext if (result.isSuccess) {
true
} else {
val reason = (result to operationResult).getErrorMessage()
Log_OC.e(TAG, "Error occurred while removing file: $reason")
false
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error occurred while removing file: $e")
false
}
}
}
}

View file

@ -6,11 +6,28 @@
*/
package com.nextcloud.client.network;
import android.os.NetworkOnMainThreadException;
/**
* This service provides information about current network connectivity
* and server reachability.
*/
public interface ConnectivityService {
/**
* Checks the availability of the server and the device's internet connection.
* <p>
* This method performs a network request to verify if the server is accessible and
* checks if the device has an active internet connection. Due to the network operations involved,
* this method should be executed on a background thread to avoid blocking the main thread.
* </p>
*
* @return {@code true} if the server is accessible and the device has an internet connection;
* {@code false} otherwise.
*
* @throws NetworkOnMainThreadException if this function runs on main thread.
*/
boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException;
boolean isConnected();
/**

View file

@ -13,6 +13,7 @@ import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
import android.os.NetworkOnMainThreadException;
import com.nextcloud.client.account.Server;
import com.nextcloud.client.account.UserAccountManager;
@ -55,6 +56,19 @@ class ConnectivityServiceImpl implements ConnectivityService {
this.walledCheckCache = walledCheckCache;
}
@Override
public boolean isNetworkAndServerAvailable() throws NetworkOnMainThreadException {
Network activeNetwork = platformConnectivityManager.getActiveNetwork();
NetworkCapabilities networkCapabilities = platformConnectivityManager.getNetworkCapabilities(activeNetwork);
boolean hasInternet = networkCapabilities != null && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
if (!hasInternet) {
return false;
}
return !isInternetWalled();
}
@Override
public boolean isConnected() {
Network nw = platformConnectivityManager.getActiveNetwork();

View file

@ -0,0 +1,12 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.model
enum class OfflineOperationType {
CreateFolder
}

View file

@ -17,4 +17,5 @@ sealed class WorkerState {
data class DownloadStarted(var user: User?, var currentDownload: DownloadFileOperation?) : WorkerState()
data class UploadFinished(var currentFile: OCFile?) : WorkerState()
data class UploadStarted(var user: User?, var uploads: List<OCUpload>) : WorkerState()
data object OfflineOperationsCompleted : WorkerState()
}

View file

@ -0,0 +1,38 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.network.ConnectivityService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
interface NetworkChangeListener {
fun networkAndServerConnectionListener(isNetworkAndServerAvailable: Boolean)
}
class NetworkChangeReceiver(
private val listener: NetworkChangeListener,
private val connectivityService: ConnectivityService
) : BroadcastReceiver() {
private val scope = CoroutineScope(Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent?) {
scope.launch {
val isNetworkAndServerAvailable = connectivityService.isNetworkAndServerAvailable()
launch(Dispatchers.Main) {
listener.networkAndServerConnectionListener(isNetworkAndServerAvailable)
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.nextcloud.utils.extensions.getParcelableArgument
import com.owncloud.android.datamodel.FileDataStorageManager
class OfflineOperationActionReceiver : BroadcastReceiver() {
companion object {
const val FILE_PATH = "FILE_PATH"
const val USER = "USER"
}
override fun onReceive(context: Context?, intent: Intent?) {
val path = intent?.getStringExtra(FILE_PATH) ?: return
val user = intent.getParcelableArgument(USER, User::class.java) ?: return
val fileDataStorageManager = FileDataStorageManager(user, context?.contentResolver)
fileDataStorageManager.offlineOperationDao.deleteByPath(path)
// TODO Update notification
}
}

View file

@ -52,7 +52,10 @@ enum class FileAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRe
LOCK_FILE(R.id.action_lock_file, R.string.lock_file, R.drawable.ic_lock),
// Shortcuts
PIN_TO_HOMESCREEN(R.id.action_pin_to_homescreen, R.string.pin_home, R.drawable.add_to_home_screen);
PIN_TO_HOMESCREEN(R.id.action_pin_to_homescreen, R.string.pin_home, R.drawable.add_to_home_screen),
// Retry for offline operation
RETRY(R.id.action_retry, R.string.retry, R.drawable.ic_retry);
companion object {
/**
@ -82,7 +85,8 @@ enum class FileAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRe
UNSET_ENCRYPTED,
SET_AS_WALLPAPER,
REMOVE_FILE,
PIN_TO_HOMESCREEN
PIN_TO_HOMESCREEN,
RETRY
)
}
}

View file

@ -0,0 +1,15 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.date
enum class DateFormatPattern(val pattern: String) {
/**
* e.g. 10.11.2024 - 12:44
*/
FullDateWithHours("dd.MM.yyyy - HH:mm")
}

View file

@ -10,6 +10,10 @@ package com.nextcloud.utils.extensions
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
fun AppCompatActivity.isDialogFragmentReady(fragment: Fragment): Boolean = isActive() && !fragment.isStateSaved()
fun AppCompatActivity.isDialogFragmentReady(fragment: Fragment): Boolean = isActive() && !fragment.isStateSaved
fun AppCompatActivity.isActive(): Boolean = !isFinishing && !isDestroyed
fun AppCompatActivity.fragments(): List<Fragment> = supportFragmentManager.fragments
fun AppCompatActivity.lastFragment(): Fragment = fragments().last()

View file

@ -0,0 +1,18 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import android.annotation.SuppressLint
import com.nextcloud.utils.date.DateFormatPattern
import java.text.SimpleDateFormat
import java.util.Date
@SuppressLint("SimpleDateFormat")
fun Date.currentDateRepresentation(formatPattern: DateFormatPattern): String {
return SimpleDateFormat(formatPattern.pattern).format(this)
}

View file

@ -0,0 +1,20 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import android.os.Parcel
import android.os.Parcelable
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(classLoader: ClassLoader?): T? {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
readParcelable(classLoader, T::class.java)
} else {
@Suppress("DEPRECATION")
readParcelable(classLoader)
}
}

View file

@ -0,0 +1,68 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.resources.files.model.RemoteFile
import com.owncloud.android.utils.ErrorMessageAdapter
import com.owncloud.android.utils.FileStorageUtils
@Suppress("ReturnCount")
fun Pair<RemoteOperationResult<*>?, RemoteOperation<*>?>?.getErrorMessage(): String {
val result = this?.first ?: return MainApp.string(R.string.unexpected_error_occurred)
val operation = this.second ?: return MainApp.string(R.string.unexpected_error_occurred)
return ErrorMessageAdapter.getErrorCauseMessage(result, operation, MainApp.getAppContext().resources)
}
@Suppress("NestedBlockDepth")
fun RemoteOperationResult<*>?.getConflictedRemoteIdsWithOfflineOperations(
offlineOperations: List<OfflineOperationEntity>,
fileDataStorageManager: FileDataStorageManager
): HashMap<String, String>? {
val newFiles = toOCFile() ?: return null
val result = hashMapOf<String, String>()
offlineOperations.forEach { operation ->
newFiles.forEach { file ->
if (fileDataStorageManager.fileExists(operation.path) && operation.filename == file.fileName) {
operation.path?.let { path ->
result[file.remoteId] = path
}
}
}
}
return result.ifEmpty { null }
}
@Suppress("Deprecation")
fun RemoteOperationResult<*>?.toOCFile(): List<OCFile>? {
return if (this?.isSuccess == true) {
data?.toOCFileList()
} else {
null
}
}
private fun ArrayList<Any>.toOCFileList(): List<OCFile> {
return this.mapNotNull {
val remoteFile = (it as? RemoteFile)
remoteFile?.let {
remoteFile.toOCFile()
}
}
}
private fun RemoteFile?.toOCFile(): OCFile = FileStorageUtils.fillOCFile(this)

View file

@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
@ -13,6 +13,11 @@ import android.util.TypedValue
import android.view.View
import android.view.ViewOutlineProvider
fun View?.setVisibleIf(condition: Boolean) {
if (this == null) return
visibility = if (condition) View.VISIBLE else View.GONE
}
fun createRoundedOutline(context: Context, cornerRadiusValue: Float): ViewOutlineProvider {
return object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {

View file

@ -31,6 +31,7 @@ import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.net.ConnectivityManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
@ -59,6 +60,8 @@ import com.nextcloud.client.onboarding.OnboardingService;
import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.client.preferences.AppPreferencesImpl;
import com.nextcloud.client.preferences.DarkMode;
import com.nextcloud.receiver.NetworkChangeListener;
import com.nextcloud.receiver.NetworkChangeReceiver;
import com.nextcloud.utils.extensions.ContextExtensionsKt;
import com.nmc.android.ui.LauncherActivity;
import com.owncloud.android.authentication.AuthenticatorActivity;
@ -129,7 +132,7 @@ import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFER
* Main Application of the project.
* Contains methods to build the "static" strings. These strings were before constants in different classes.
*/
public class MainApp extends Application implements HasAndroidInjector {
public class MainApp extends Application implements HasAndroidInjector, NetworkChangeListener {
public static final OwnCloudVersion OUTDATED_SERVER_VERSION = NextcloudVersion.nextcloud_26;
public static final OwnCloudVersion MINIMUM_SUPPORTED_SERVER_VERSION = OwnCloudVersion.nextcloud_17;
@ -204,6 +207,8 @@ public class MainApp extends Application implements HasAndroidInjector {
private static AppComponent appComponent;
private NetworkChangeReceiver networkChangeReceiver;
/**
* Temporary hack
*/
@ -227,6 +232,11 @@ public class MainApp extends Application implements HasAndroidInjector {
return powerManagementService;
}
private void registerNetworkChangeReceiver() {
IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
registerReceiver(networkChangeReceiver, filter);
}
private String getAppProcessName() {
String processName = "";
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
@ -372,9 +382,12 @@ public class MainApp extends Application implements HasAndroidInjector {
backgroundJobManager.startMediaFoldersDetectionJob();
backgroundJobManager.schedulePeriodicHealthStatus();
backgroundJobManager.scheduleInternal2WaySync();
backgroundJobManager.startPeriodicallyOfflineOperation();
}
registerGlobalPassCodeProtection();
networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService);
registerNetworkChangeReceiver();
}
private final LifecycleEventObserver lifecycleEventObserver = ((lifecycleOwner, event) -> {
@ -670,6 +683,10 @@ public class MainApp extends Application implements HasAndroidInjector {
R.string.notification_channel_push_name, R.string
.notification_channel_push_description, context, NotificationManager.IMPORTANCE_DEFAULT);
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS,
R.string.notification_channel_background_operations_name, R.string
.notification_channel_background_operations_description, context, NotificationManager.IMPORTANCE_DEFAULT);
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_GENERAL, R.string
.notification_channel_general_name, R.string.notification_channel_general_description,
context, NotificationManager.IMPORTANCE_DEFAULT);
@ -974,4 +991,16 @@ public class MainApp extends Application implements HasAndroidInjector {
case SYSTEM -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
}
}
@Override
public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailable) {
if (backgroundJobManager == null) {
Log_OC.d(TAG, "Offline operations terminated, backgroundJobManager cannot be null");
return;
}
if (isNetworkAndServerAvailable) {
backgroundJobManager.startOfflineOperations();
}
}
}

View file

@ -13,6 +13,7 @@
*/
package com.owncloud.android.datamodel;
import android.annotation.SuppressLint;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
@ -34,7 +35,14 @@ import com.google.gson.JsonSyntaxException;
import com.nextcloud.client.account.User;
import com.nextcloud.client.database.NextcloudDatabase;
import com.nextcloud.client.database.dao.FileDao;
import com.nextcloud.client.database.dao.OfflineOperationDao;
import com.nextcloud.client.database.entity.FileEntity;
import com.nextcloud.client.database.entity.OfflineOperationEntity;
import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository;
import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepositoryType;
import com.nextcloud.model.OfflineOperationType;
import com.nextcloud.utils.date.DateFormatPattern;
import com.nextcloud.utils.extensions.DateExtensionsKt;
import com.owncloud.android.MainApp;
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta;
import com.owncloud.android.lib.common.network.WebdavEntry;
@ -65,6 +73,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@ -94,19 +103,23 @@ public class FileDataStorageManager {
private final ContentProviderClient contentProviderClient;
private final User user;
public final OfflineOperationDao offlineOperationDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).offlineOperationDao();
private final FileDao fileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).fileDao();
private final Gson gson = new Gson();
private final OfflineOperationsRepositoryType offlineOperationsRepository;
public FileDataStorageManager(User user, ContentResolver contentResolver) {
this.contentProviderClient = null;
this.contentResolver = contentResolver;
this.user = user;
offlineOperationsRepository = new OfflineOperationsRepository(this);
}
public FileDataStorageManager(User user, ContentProviderClient contentProviderClient) {
this.contentProviderClient = contentProviderClient;
this.contentResolver = null;
this.user = user;
offlineOperationsRepository = new OfflineOperationsRepository(this);
}
/**
@ -126,6 +139,73 @@ public class FileDataStorageManager {
return getFileByPath(ProviderTableMeta.FILE_PATH_DECRYPTED, path);
}
public OfflineOperationEntity addCreateFolderOfflineOperation(String path, String filename, String parentPath, Long parentOCFileId) {
OfflineOperationEntity entity = new OfflineOperationEntity();
entity.setFilename(filename);
entity.setParentOCFileId(parentOCFileId);
entity.setPath(path);
entity.setParentPath(parentPath);
entity.setCreatedAt(System.currentTimeMillis() / 1000L);
entity.setType(OfflineOperationType.CreateFolder);
offlineOperationDao.insert(entity);
createPendingDirectory(path);
return entity;
}
public void createPendingDirectory(String path) {
OCFile file = new OCFile(path);
file.setMimeType(MimeType.DIRECTORY);
saveFileWithParent(file, MainApp.getAppContext());
}
public void deleteOfflineOperation(OCFile file) {
offlineOperationsRepository.deleteOperation(file);
}
public void renameCreateFolderOfflineOperation(OCFile file, String newFolderName) {
var entity = offlineOperationDao.getByPath(file.getDecryptedRemotePath());
if (entity == null) {
return;
}
OCFile parentFolder = getFileById(file.getParentId());
if (parentFolder == null) {
return;
}
String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR;
entity.setPath(newPath);
entity.setFilename(newFolderName);
offlineOperationDao.update(entity);
moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath());
}
@SuppressLint("SimpleDateFormat")
public void keepOfflineOperationAndServerFile(OfflineOperationEntity entity, OCFile file) {
if (file == null) return;
String oldFileName = entity.getFilename();
if (oldFileName == null) return;
Long parentOCFileId = entity.getParentOCFileId();
if (parentOCFileId == null) return;
OCFile parentFolder = getFileById(parentOCFileId);
if (parentFolder == null) return;
DateFormatPattern formatPattern = DateFormatPattern.FullDateWithHours;
String currentDateTime = DateExtensionsKt.currentDateRepresentation(new Date(), formatPattern);
String newFolderName = oldFileName + " - " + currentDateTime;
String newPath = parentFolder.getDecryptedRemotePath() + newFolderName + OCFile.PATH_SEPARATOR;
moveLocalFile(file, newPath, parentFolder.getDecryptedRemotePath());
offlineOperationsRepository.updateNextOperations(entity);
}
private @Nullable
OCFile getFileByPath(String type, String path) {
final boolean shouldUseEncryptedPath = ProviderTableMeta.FILE_PATH.equals(type);
@ -171,7 +251,9 @@ public class FileDataStorageManager {
return null;
}
public boolean fileExists(long id) { return fileDao.getFileById(id) != null; }
public boolean fileExists(long id) {
return fileDao.getFileById(id) != null;
}
public boolean fileExists(String path) {
return fileDao.getFileByEncryptedRemotePath(path, user.getAccountName()) != null;
@ -364,19 +446,19 @@ public class FileDataStorageManager {
}
public static void clearTempEncryptedFolder(String accountName) {
File tempEncryptedFolder = new File(FileStorageUtils.getTemporalEncryptedFolderPath(accountName));
File tempEncryptedFolder = new File(FileStorageUtils.getTemporalEncryptedFolderPath(accountName));
if (!tempEncryptedFolder.exists()) {
Log_OC.d(TAG,"tempEncryptedFolder does not exist");
Log_OC.d(TAG, "tempEncryptedFolder does not exist");
return;
}
try {
FileUtils.cleanDirectory(tempEncryptedFolder);
Log_OC.d(TAG,"tempEncryptedFolder cleared");
Log_OC.d(TAG, "tempEncryptedFolder cleared");
} catch (IOException exception) {
Log_OC.d(TAG,"Error caught at clearTempEncryptedFolder: " + exception);
Log_OC.d(TAG, "Error caught at clearTempEncryptedFolder: " + exception);
}
}
@ -409,7 +491,7 @@ public class FileDataStorageManager {
/**
* Inserts or updates the list of files contained in a given folder.
*
* <p>
* CALLER IS RESPONSIBLE FOR GRANTING RIGHT UPDATE OF INFORMATION, NOT THIS METHOD. HERE ONLY DATA CONSISTENCY
* SHOULD BE GRANTED
*
@ -457,7 +539,7 @@ public class FileDataStorageManager {
whereArgs[1] = ocFile.getRemotePath();
if (ocFile.isFolder()) {
operations.add(ContentProviderOperation.newDelete(
ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, ocFile.getFileId()))
ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, ocFile.getFileId()))
.withSelection(where, whereArgs).build());
File localFolder = new File(FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile));
@ -466,7 +548,7 @@ public class FileDataStorageManager {
}
} else {
operations.add(ContentProviderOperation.newDelete(
ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, ocFile.getFileId()))
ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, ocFile.getFileId()))
.withSelection(where, whereArgs).build());
if (ocFile.isDown()) {
@ -526,6 +608,7 @@ public class FileDataStorageManager {
/**
* Returns a {@link ContentValues} filled with values that are common to both files and folders
*
* @see #createContentValuesForFile(OCFile)
* @see #createContentValuesForFolder(OCFile)
*/
@ -566,6 +649,7 @@ public class FileDataStorageManager {
/**
* Returns a {@link ContentValues} filled with values for a folder
*
* @see #createContentValuesForFile(OCFile)
* @see #createContentValuesBase(OCFile)
*/
@ -577,6 +661,7 @@ public class FileDataStorageManager {
/**
* Returns a {@link ContentValues} filled with values for a file
*
* @see #createContentValuesForFolder(OCFile)
* @see #createContentValuesBase(OCFile)
*/
@ -748,7 +833,7 @@ public class FileDataStorageManager {
/**
* Updates database and file system for a file or folder that was moved to a different location.
*
* <p>
* TODO explore better (faster) implementations TODO throw exceptions up !
*/
public void moveLocalFile(OCFile ocFile, String targetPath, String targetParentPath) {
@ -773,7 +858,7 @@ public class FileDataStorageManager {
int lengthOfOldPath = oldPath.length();
int lengthOfOldStoragePath = defaultSavePath.length() + lengthOfOldPath;
for (FileEntity fileEntity: fileEntities) {
for (FileEntity fileEntity : fileEntities) {
ContentValues contentValues = new ContentValues(); // keep construction in the loop
OCFile childFile = createFileInstance(fileEntity);
contentValues.put(
@ -876,8 +961,8 @@ public class FileDataStorageManager {
}
/**
* This method does not require {@link FileDataStorageManager} being initialized
* with any specific user. Migration can be performed with {@link com.nextcloud.client.account.AnonymousUser}.
* This method does not require {@link FileDataStorageManager} being initialized with any specific user. Migration
* can be performed with {@link com.nextcloud.client.account.AnonymousUser}.
*/
public void migrateStoredFiles(String sourcePath, String destinationPath)
throws RemoteException, OperationApplicationException {
@ -909,7 +994,7 @@ public class FileDataStorageManager {
ContentValues cv = new ContentValues();
fileId[0] = String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ProviderTableMeta._ID)));
String oldFileStoragePath =
cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_STORAGE_PATH));
cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_STORAGE_PATH));
if (oldFileStoragePath.startsWith(sourcePath)) {
@ -940,7 +1025,7 @@ public class FileDataStorageManager {
List<OCFile> folderContent = new ArrayList<>();
List<FileEntity> files = fileDao.getFolderContent(parentId);
for (FileEntity fileEntity: files) {
for (FileEntity fileEntity : files) {
OCFile child = createFileInstance(fileEntity);
if (!onlyOnDevice || child.existsOnDevice()) {
folderContent.add(child);
@ -1203,7 +1288,7 @@ public class FileDataStorageManager {
+ ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?",
new String[]{value, user.getAccountName()},
null
);
);
} else {
try {
cursor = getContentProviderClient().query(
@ -1212,7 +1297,7 @@ public class FileDataStorageManager {
key + AND + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?",
new String[]{value, user.getAccountName()},
null
);
);
} catch (RemoteException e) {
Log_OC.w(TAG, "Could not get details, assuming share does not exist: " + e.getMessage());
cursor = null;
@ -1451,7 +1536,7 @@ public class FileDataStorageManager {
ContentProviderOperation.newInsert(ProviderTableMeta.CONTENT_URI_SHARE)
.withValues(contentValues)
.build()
);
);
}
}
@ -1611,7 +1696,7 @@ public class FileDataStorageManager {
ContentProviderOperation.newDelete(ProviderTableMeta.CONTENT_URI_SHARE).
withSelection(where, whereArgs).
build()
);
);
}
}
return preparedOperations;
@ -1629,7 +1714,7 @@ public class FileDataStorageManager {
.newDelete(ProviderTableMeta.CONTENT_URI_SHARE)
.withSelection(where, whereArgs)
.build()
);
);
return preparedOperations;
@ -1780,7 +1865,7 @@ public class FileDataStorageManager {
cv,
ProviderTableMeta._ID + "=?",
new String[]{String.valueOf(ocFile.getFileId())}
);
);
} else {
try {
updated = getContentProviderClient().update(
@ -1788,7 +1873,7 @@ public class FileDataStorageManager {
cv,
ProviderTableMeta._ID + "=?",
new String[]{String.valueOf(ocFile.getFileId())}
);
);
} catch (RemoteException e) {
Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e);
}
@ -1822,7 +1907,7 @@ public class FileDataStorageManager {
cv,
stringBuilder.toString(),
ancestorIds.toArray(new String[]{})
);
);
} else {
try {
updated = getContentProviderClient().update(
@ -1830,7 +1915,7 @@ public class FileDataStorageManager {
cv,
stringBuilder.toString(),
ancestorIds.toArray(new String[]{})
);
);
} catch (RemoteException e) {
Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e);
}
@ -1862,7 +1947,7 @@ public class FileDataStorageManager {
whereForDescencentsInConflict,
new String[]{user.getAccountName(), parentPath + '%'},
null
);
);
} else {
try {
descendentsInConflict = getContentProviderClient().query(
@ -1871,7 +1956,7 @@ public class FileDataStorageManager {
whereForDescencentsInConflict,
new String[]{user.getAccountName(), parentPath + "%"},
null
);
);
} catch (RemoteException e) {
Log_OC.e(TAG, "Failed querying for descendents in conflict " + e.getMessage(), e);
}
@ -1886,7 +1971,7 @@ public class FileDataStorageManager {
ProviderTableMeta.FILE_ACCOUNT_OWNER + AND +
ProviderTableMeta.FILE_PATH + "=?",
new String[]{user.getAccountName(), parentPath}
);
);
} else {
try {
updated = getContentProviderClient().update(
@ -1895,7 +1980,7 @@ public class FileDataStorageManager {
ProviderTableMeta.FILE_ACCOUNT_OWNER + AND +
ProviderTableMeta.FILE_PATH + "=?"
, new String[]{user.getAccountName(), parentPath}
);
);
} catch (RemoteException e) {
Log_OC.e(TAG, "Failed saving conflict in database " + e.getMessage(), e);
}
@ -2285,7 +2370,7 @@ public class FileDataStorageManager {
Log_OC.d(TAG, "getGalleryItems - query complete, list size: " + fileEntities.size());
List<OCFile> files = new ArrayList<>(fileEntities.size());
for (FileEntity fileEntity: fileEntities) {
for (FileEntity fileEntity : fileEntities) {
files.add(createFileInstance(fileEntity));
}
@ -2306,7 +2391,7 @@ public class FileDataStorageManager {
ProviderTableMeta.VIRTUAL_TYPE + "=?",
new String[]{String.valueOf(type)},
null
);
);
} catch (RemoteException e) {
Log_OC.e(TAG, e.getMessage(), e);
return ocFiles;
@ -2318,7 +2403,7 @@ public class FileDataStorageManager {
ProviderTableMeta.VIRTUAL_TYPE + "=?",
new String[]{String.valueOf(type)},
null
);
);
}
if (c != null) {
@ -2397,7 +2482,7 @@ public class FileDataStorageManager {
List<OCFile> files = getAllFilesRecursivelyInsideFolder(folder);
List<Pair<String, String>> decryptedFileNamesAndEncryptedRemotePaths = getDecryptedFileNamesAndEncryptedRemotePaths(files);
String decryptedFileName = decryptedRemotePath.substring( decryptedRemotePath.lastIndexOf('/') + 1);
String decryptedFileName = decryptedRemotePath.substring(decryptedRemotePath.lastIndexOf('/') + 1);
for (Pair<String, String> item : decryptedFileNamesAndEncryptedRemotePaths) {
if (item.getFirst().equals(decryptedFileName)) {
@ -2434,7 +2519,7 @@ public class FileDataStorageManager {
List<FileEntity> fileEntities = fileDao.getAllFiles(user.getAccountName());
List<OCFile> folderContent = new ArrayList<>(fileEntities.size());
for (FileEntity fileEntity: fileEntities) {
for (FileEntity fileEntity : fileEntities) {
folderContent.add(createFileInstance(fileEntity));
}
@ -2483,7 +2568,7 @@ public class FileDataStorageManager {
return files;
}
public List<OCFile> getInternalTwoWaySyncFolders(User user) {
List<FileEntity> fileEntities = fileDao.getInternalTwoWaySyncFolders(user.getAccountName());
List<OCFile> files = new ArrayList<>(fileEntities.size());
@ -2494,7 +2579,7 @@ public class FileDataStorageManager {
return files;
}
public boolean isPartOfInternalTwoWaySync(OCFile file) {
if (file.isInternalFolderSync()) {
return true;

View file

@ -35,6 +35,7 @@ import com.owncloud.android.utils.MimeType;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -775,6 +776,26 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
return this.downloading;
}
public boolean isRootDirectory() {
return decryptedRemotePath.equals(ROOT_PATH);
}
public boolean isOfflineOperation() {
return getRemoteId() == null;
}
public String getOfflineOperationParentPath() {
if (isOfflineOperation()) {
if (Objects.equals(remotePath, OCFile.PATH_SEPARATOR)) {
return OCFile.PATH_SEPARATOR;
} else {
return null;
}
} else {
return getDecryptedRemotePath();
}
}
public String getEtagInConflict() {
return this.etagInConflict;
}

View file

@ -25,13 +25,14 @@ import java.util.List;
*/
public class ProviderMeta {
public static final String DB_NAME = "filelist";
public static final int DB_VERSION = 83;
public static final int DB_VERSION = 84;
private ProviderMeta() {
// No instance
}
static public class ProviderTableMeta implements BaseColumns {
public static final String OFFLINE_OPERATION_TABLE_NAME = "offline_operations";
public static final String FILE_TABLE_NAME = "filelist";
public static final String OCSHARES_TABLE_NAME = "ocshares";
public static final String CAPABILITIES_TABLE_NAME = "capabilities";
@ -47,24 +48,24 @@ public class ProviderMeta {
private static final String CONTENT_PREFIX = "content://";
public static final Uri CONTENT_URI = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/");
+ MainApp.getAuthority() + "/");
public static final Uri CONTENT_URI_FILE = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/file");
+ MainApp.getAuthority() + "/file");
public static final Uri CONTENT_URI_DIR = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/dir");
+ MainApp.getAuthority() + "/dir");
public static final Uri CONTENT_URI_SHARE = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/shares");
+ MainApp.getAuthority() + "/shares");
public static final Uri CONTENT_URI_CAPABILITIES = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/capabilities");
+ MainApp.getAuthority() + "/capabilities");
public static final Uri CONTENT_URI_UPLOADS = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/uploads");
+ MainApp.getAuthority() + "/uploads");
public static final Uri CONTENT_URI_SYNCED_FOLDERS = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/synced_folders");
+ MainApp.getAuthority() + "/synced_folders");
public static final Uri CONTENT_URI_EXTERNAL_LINKS = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/external_links");
+ MainApp.getAuthority() + "/external_links");
public static final Uri CONTENT_URI_VIRTUAL = Uri.parse(CONTENT_PREFIX + MainApp.getAuthority() + "/virtual");
public static final Uri CONTENT_URI_FILESYSTEM = Uri.parse(CONTENT_PREFIX
+ MainApp.getAuthority() + "/filesystem");
+ MainApp.getAuthority() + "/filesystem");
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.owncloud.file";
@ -124,58 +125,58 @@ public class ProviderMeta {
public static final String FILE_INTERNAL_TWO_WAY_SYNC_RESULT = "internal_two_way_sync_result";
public static final List<String> FILE_ALL_COLUMNS = Collections.unmodifiableList(Arrays.asList(
_ID,
FILE_PARENT,
FILE_NAME,
FILE_ENCRYPTED_NAME,
FILE_CREATION,
FILE_MODIFIED,
FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
FILE_CONTENT_LENGTH,
FILE_CONTENT_TYPE,
FILE_STORAGE_PATH,
FILE_PATH,
FILE_PATH_DECRYPTED,
FILE_ACCOUNT_OWNER,
FILE_LAST_SYNC_DATE,
FILE_LAST_SYNC_DATE_FOR_DATA,
FILE_KEEP_IN_SYNC,
FILE_ETAG,
FILE_ETAG_ON_SERVER,
FILE_SHARED_VIA_LINK,
FILE_SHARED_WITH_SHAREE,
FILE_PERMISSIONS,
FILE_REMOTE_ID,
FILE_LOCAL_ID,
FILE_UPDATE_THUMBNAIL,
FILE_IS_DOWNLOADING,
FILE_ETAG_IN_CONFLICT,
FILE_FAVORITE,
FILE_HIDDEN,
FILE_IS_ENCRYPTED,
FILE_MOUNT_TYPE,
FILE_HAS_PREVIEW,
FILE_UNREAD_COMMENTS_COUNT,
FILE_OWNER_ID,
FILE_OWNER_DISPLAY_NAME,
FILE_NOTE,
FILE_SHAREES,
FILE_RICH_WORKSPACE,
FILE_LOCKED,
FILE_LOCK_TYPE,
FILE_LOCK_OWNER,
FILE_LOCK_OWNER_DISPLAY_NAME,
FILE_LOCK_OWNER_EDITOR,
FILE_LOCK_TIMESTAMP,
FILE_LOCK_TIMEOUT,
FILE_LOCK_TOKEN,
FILE_METADATA_SIZE,
FILE_METADATA_LIVE_PHOTO,
FILE_E2E_COUNTER,
FILE_TAGS,
FILE_METADATA_GPS,
FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP,
FILE_INTERNAL_TWO_WAY_SYNC_RESULT));
_ID,
FILE_PARENT,
FILE_NAME,
FILE_ENCRYPTED_NAME,
FILE_CREATION,
FILE_MODIFIED,
FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
FILE_CONTENT_LENGTH,
FILE_CONTENT_TYPE,
FILE_STORAGE_PATH,
FILE_PATH,
FILE_PATH_DECRYPTED,
FILE_ACCOUNT_OWNER,
FILE_LAST_SYNC_DATE,
FILE_LAST_SYNC_DATE_FOR_DATA,
FILE_KEEP_IN_SYNC,
FILE_ETAG,
FILE_ETAG_ON_SERVER,
FILE_SHARED_VIA_LINK,
FILE_SHARED_WITH_SHAREE,
FILE_PERMISSIONS,
FILE_REMOTE_ID,
FILE_LOCAL_ID,
FILE_UPDATE_THUMBNAIL,
FILE_IS_DOWNLOADING,
FILE_ETAG_IN_CONFLICT,
FILE_FAVORITE,
FILE_HIDDEN,
FILE_IS_ENCRYPTED,
FILE_MOUNT_TYPE,
FILE_HAS_PREVIEW,
FILE_UNREAD_COMMENTS_COUNT,
FILE_OWNER_ID,
FILE_OWNER_DISPLAY_NAME,
FILE_NOTE,
FILE_SHAREES,
FILE_RICH_WORKSPACE,
FILE_LOCKED,
FILE_LOCK_TYPE,
FILE_LOCK_OWNER,
FILE_LOCK_OWNER_DISPLAY_NAME,
FILE_LOCK_OWNER_EDITOR,
FILE_LOCK_TIMESTAMP,
FILE_LOCK_TIMEOUT,
FILE_LOCK_TOKEN,
FILE_METADATA_SIZE,
FILE_METADATA_LIVE_PHOTO,
FILE_E2E_COUNTER,
FILE_TAGS,
FILE_METADATA_GPS,
FILE_INTERNAL_TWO_WAY_SYNC_TIMESTAMP,
FILE_INTERNAL_TWO_WAY_SYNC_RESULT));
public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME + " collate nocase asc";
// Columns of ocshares table
@ -200,7 +201,7 @@ public class ProviderMeta {
public static final String OCSHARES_SHARE_LABEL = "share_label";
public static final String OCSHARES_DEFAULT_SORT_ORDER = OCSHARES_FILE_SOURCE
+ " collate nocase asc";
+ " collate nocase asc";
// Columns of capabilities table
public static final String CAPABILITIES_ACCOUNT_NAME = "account";
@ -217,11 +218,11 @@ public class ProviderMeta {
public static final String CAPABILITIES_SHARING_PUBLIC_ASK_FOR_OPTIONAL_PASSWORD =
"sharing_public_ask_for_optional_password";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED =
"sharing_public_expire_date_enabled";
"sharing_public_expire_date_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS =
"sharing_public_expire_date_days";
"sharing_public_expire_date_days";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED =
"sharing_public_expire_date_enforced";
"sharing_public_expire_date_enforced";
public static final String CAPABILITIES_SHARING_PUBLIC_SEND_MAIL = "sharing_public_send_mail";
public static final String CAPABILITIES_SHARING_PUBLIC_UPLOAD = "sharing_public_upload";
public static final String CAPABILITIES_SHARING_USER_SEND_MAIL = "sharing_user_send_mail";
@ -286,6 +287,15 @@ public class ProviderMeta {
public static final String UPLOADS_IS_WIFI_ONLY = "is_wifi_only";
public static final String UPLOADS_FOLDER_UNLOCK_TOKEN = "folder_unlock_token";
// Columns of offline operation table
public static final String OFFLINE_OPERATION_PARENT_OC_FILE_ID = "offline_operations_parent_oc_file_id";
public static final String OFFLINE_OPERATION_PARENT_PATH = "offline_operations_parent_path";
public static final String OFFLINE_OPERATION_TYPE = "offline_operations_type";
public static final String OFFLINE_OPERATION_PATH = "offline_operations_path";
public static final String OFFLINE_OPERATION_CREATED_AT = "offline_operations_created_at";
public static final String OFFLINE_OPERATION_FILE_NAME = "offline_operations_file_name";
// Columns of synced folder table
public static final String SYNCED_FOLDER_LOCAL_PATH = "local_path";
public static final String SYNCED_FOLDER_REMOTE_PATH = "remote_path";

View file

@ -169,6 +169,7 @@ public class FileMenuFilter {
filterLock(toHide, fileLockingEnabled);
filterUnlock(toHide, fileLockingEnabled);
filterPinToHome(toHide);
filterRetry(toHide);
return toHide;
}
@ -260,6 +261,12 @@ public class FileMenuFilter {
}
}
private void filterRetry(List<Integer> toHide) {
if (!files.iterator().next().isOfflineOperation()) {
toHide.add(R.id.action_retry);
}
}
private void filterEdit(
List<Integer> toHide,
OCCapability capability

View file

@ -16,6 +16,7 @@ import com.google.gson.Gson;
import com.nextcloud.android.lib.resources.directediting.DirectEditingObtainRemoteOperation;
import com.nextcloud.client.account.User;
import com.nextcloud.common.NextcloudClient;
import com.nextcloud.utils.extensions.RemoteOperationResultExtensionsKt;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl;
import com.owncloud.android.datamodel.FileDataStorageManager;
@ -42,6 +43,7 @@ import com.owncloud.android.lib.resources.status.E2EVersion;
import com.owncloud.android.lib.resources.users.GetPredefinedStatusesRemoteOperation;
import com.owncloud.android.lib.resources.users.PredefinedStatus;
import com.owncloud.android.syncadapter.FileSyncAdapter;
import com.owncloud.android.ui.activity.FileDisplayActivity;
import com.owncloud.android.utils.DataHolderUtil;
import com.owncloud.android.utils.EncryptionUtils;
import com.owncloud.android.utils.FileStorageUtils;
@ -80,7 +82,7 @@ public class RefreshFolderOperation extends RemoteOperation {
/**
* Time stamp for the synchronization process in progress
*/
private long mCurrentSyncTime;
private final long mCurrentSyncTime;
/**
* Remote folder to synchronize
@ -90,17 +92,17 @@ public class RefreshFolderOperation extends RemoteOperation {
/**
* Access to the local database
*/
private FileDataStorageManager mStorageManager;
private final FileDataStorageManager fileDataStorageManager;
/**
* Account where the file to synchronize belongs
*/
private User user;
private final User user;
/**
* Android context; necessary to send requests to the download service
*/
private Context mContext;
private final Context mContext;
/**
* Files and folders contained in the synchronized folder after a successful operation
@ -121,12 +123,12 @@ public class RefreshFolderOperation extends RemoteOperation {
* Map of remote and local paths to files that where locally stored in a location out of the ownCloud folder and
* couldn't be copied automatically into it
**/
private Map<String, String> mForgottenLocalFiles;
private final Map<String, String> mForgottenLocalFiles;
/**
* 'True' means that this operation is part of a full account synchronization
*/
private boolean mSyncFullAccount;
private final boolean mSyncFullAccount;
/**
* 'True' means that the remote folder changed and should be fetched
@ -136,14 +138,14 @@ public class RefreshFolderOperation extends RemoteOperation {
/**
* 'True' means that Etag will be ignored
*/
private boolean mIgnoreETag;
private final boolean mIgnoreETag;
/**
* 'True' means that no share and no capabilities will be updated
*/
private boolean mOnlyFileMetadata;
private final boolean mOnlyFileMetadata;
private List<SynchronizeFileOperation> mFilesToSyncContents;
private final List<SynchronizeFileOperation> mFilesToSyncContents;
// this will be used for every file when 'folder synchronization' replaces 'folder download'
@ -169,7 +171,7 @@ public class RefreshFolderOperation extends RemoteOperation {
mLocalFolder = folder;
mCurrentSyncTime = currentSyncTime;
mSyncFullAccount = syncFullAccount;
mStorageManager = dataStorageManager;
fileDataStorageManager = dataStorageManager;
this.user = user;
mContext = context;
mForgottenLocalFiles = new HashMap<>();
@ -190,7 +192,7 @@ public class RefreshFolderOperation extends RemoteOperation {
mLocalFolder = folder;
mCurrentSyncTime = currentSyncTime;
mSyncFullAccount = syncFullAccount;
mStorageManager = dataStorageManager;
fileDataStorageManager = dataStorageManager;
this.user = user;
mContext = context;
mForgottenLocalFiles = new HashMap<>();
@ -246,7 +248,7 @@ public class RefreshFolderOperation extends RemoteOperation {
// TODO catch IllegalStateException, show properly to user
result = fetchAndSyncRemoteFolder(client);
} else {
mChildren = mStorageManager.getFolderContent(mLocalFolder, false);
mChildren = fileDataStorageManager.getFolderContent(mLocalFolder, false);
}
if (result.isSuccess()) {
@ -257,13 +259,13 @@ public class RefreshFolderOperation extends RemoteOperation {
}
mLocalFolder.setLastSyncDateForData(System.currentTimeMillis());
mStorageManager.saveFile(mLocalFolder);
fileDataStorageManager.saveFile(mLocalFolder);
}
checkFolderConflictData(result);
if (!mSyncFullAccount && mRemoteFolderChanged) {
sendLocalBroadcast(
EVENT_SINGLE_FOLDER_CONTENTS_SYNCED, mLocalFolder.getRemotePath(), result
);
sendLocalBroadcast(EVENT_SINGLE_FOLDER_CONTENTS_SYNCED, mLocalFolder.getRemotePath(), result);
}
if (result.isSuccess() && !mSyncFullAccount && !mOnlyFileMetadata) {
@ -271,13 +273,29 @@ public class RefreshFolderOperation extends RemoteOperation {
}
if (!mSyncFullAccount) {
sendLocalBroadcast(
EVENT_SINGLE_FOLDER_SHARES_SYNCED, mLocalFolder.getRemotePath(), result
);
sendLocalBroadcast(EVENT_SINGLE_FOLDER_SHARES_SYNCED, mLocalFolder.getRemotePath(), result);
}
return result;
}
private static HashMap<String, String> lastConflictData = new HashMap<>();
private void checkFolderConflictData(RemoteOperationResult result) {
var offlineOperations = fileDataStorageManager.offlineOperationDao.getAll();
if (offlineOperations.isEmpty()) return;
var conflictData = RemoteOperationResultExtensionsKt.getConflictedRemoteIdsWithOfflineOperations(result, offlineOperations, fileDataStorageManager);
if (conflictData != null && !conflictData.equals(lastConflictData)) {
lastConflictData = new HashMap<>(conflictData);
sendFolderSyncConflictEventBroadcast(conflictData);
}
}
private void sendFolderSyncConflictEventBroadcast(HashMap<String, String> conflictData) {
Intent intent = new Intent(FileDisplayActivity.FOLDER_SYNC_CONFLICT);
intent.putExtra(FileDisplayActivity.FOLDER_SYNC_CONFLICT_ARG_REMOTE_IDS_TO_OPERATION_PATHS, conflictData);
LocalBroadcastManager.getInstance(mContext).sendBroadcast(intent);
}
private void updateOCVersion(OwnCloudClient client) {
@ -293,7 +311,7 @@ public class RefreshFolderOperation extends RemoteOperation {
try {
NextcloudClient nextcloudClient = OwnCloudClientFactory.createNextcloudClient(user, mContext);
RemoteOperationResult<UserInfo> result = new GetUserProfileOperation(mStorageManager).execute(nextcloudClient);
RemoteOperationResult<UserInfo> result = new GetUserProfileOperation(fileDataStorageManager).execute(nextcloudClient);
if (!result.isSuccess()) {
Log_OC.w(TAG, "Couldn't update user profile from server");
} else {
@ -309,9 +327,9 @@ public class RefreshFolderOperation extends RemoteOperation {
String oldDirectEditingEtag = arbitraryDataProvider.getValue(user,
ArbitraryDataProvider.DIRECT_EDITING_ETAG);
RemoteOperationResult result = new GetCapabilitiesOperation(mStorageManager).execute(mContext);
RemoteOperationResult result = new GetCapabilitiesOperation(fileDataStorageManager).execute(mContext);
if (result.isSuccess()) {
String newDirectEditingEtag = mStorageManager.getCapability(user.getAccountName()).getDirectEditingEtag();
String newDirectEditingEtag = fileDataStorageManager.getCapability(user.getAccountName()).getDirectEditingEtag();
if (!oldDirectEditingEtag.equalsIgnoreCase(newDirectEditingEtag)) {
updateDirectEditing(arbitraryDataProvider, newDirectEditingEtag);
@ -430,13 +448,13 @@ public class RefreshFolderOperation extends RemoteOperation {
}
private void removeLocalFolder() {
if (mStorageManager.fileExists(mLocalFolder.getFileId())) {
if (fileDataStorageManager.fileExists(mLocalFolder.getFileId())) {
String currentSavePath = FileStorageUtils.getSavePath(user.getAccountName());
mStorageManager.removeFolder(
fileDataStorageManager.removeFolder(
mLocalFolder,
true,
mLocalFolder.isDown() && mLocalFolder.getStoragePath().startsWith(currentSavePath)
);
);
}
}
@ -451,7 +469,7 @@ public class RefreshFolderOperation extends RemoteOperation {
*/
private void synchronizeData(List<Object> folderAndFiles) {
// get 'fresh data' from the database
mLocalFolder = mStorageManager.getFileByPath(mLocalFolder.getRemotePath());
mLocalFolder = fileDataStorageManager.getFileByPath(mLocalFolder.getRemotePath());
if (mLocalFolder == null) {
Log_OC.d(TAG,"mLocalFolder cannot be null");
@ -469,7 +487,7 @@ public class RefreshFolderOperation extends RemoteOperation {
mFilesToSyncContents.clear();
// if local folder is encrypted, download fresh metadata
boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(mLocalFolder, mStorageManager);
boolean encryptedAncestor = FileStorageUtils.checkEncryptionStatus(mLocalFolder, fileDataStorageManager);
mLocalFolder.setEncrypted(encryptedAncestor);
// update permission
@ -505,11 +523,11 @@ public class RefreshFolderOperation extends RemoteOperation {
if (object instanceof DecryptedFolderMetadataFileV1) {
e2EVersion = E2EVersion.V1_2;
localFilesMap = prefillLocalFilesMap((DecryptedFolderMetadataFileV1) object,
mStorageManager.getFolderContent(mLocalFolder, false));
fileDataStorageManager.getFolderContent(mLocalFolder, false));
} else {
e2EVersion = E2EVersion.V2_0;
localFilesMap = prefillLocalFilesMap((DecryptedFolderMetadataFile) object,
mStorageManager.getFolderContent(mLocalFolder, false));
fileDataStorageManager.getFolderContent(mLocalFolder, false));
// update counter
if (object != null) {
@ -537,7 +555,7 @@ public class RefreshFolderOperation extends RemoteOperation {
// TODO better implementation is needed
if (localFile == null) {
localFile = mStorageManager.getFileByPath(updatedFile.getRemotePath());
localFile = fileDataStorageManager.getFileByPath(updatedFile.getRemotePath());
}
// add to updatedFile data about LOCAL STATE (not existing in server)
@ -556,11 +574,11 @@ public class RefreshFolderOperation extends RemoteOperation {
// update file name for encrypted files
if (e2EVersion == E2EVersion.V1_2) {
updateFileNameForEncryptedFileV1(mStorageManager,
updateFileNameForEncryptedFileV1(fileDataStorageManager,
(DecryptedFolderMetadataFileV1) object,
updatedFile);
} else {
updateFileNameForEncryptedFile(mStorageManager,
updateFileNameForEncryptedFile(fileDataStorageManager,
(DecryptedFolderMetadataFile) object,
updatedFile);
if (localFile != null) {
@ -579,15 +597,15 @@ public class RefreshFolderOperation extends RemoteOperation {
// save updated contents in local database
// update file name for encrypted files
if (e2EVersion == E2EVersion.V1_2) {
updateFileNameForEncryptedFileV1(mStorageManager,
updateFileNameForEncryptedFileV1(fileDataStorageManager,
(DecryptedFolderMetadataFileV1) object,
mLocalFolder);
} else {
updateFileNameForEncryptedFile(mStorageManager,
updateFileNameForEncryptedFile(fileDataStorageManager,
(DecryptedFolderMetadataFile) object,
mLocalFolder);
}
mStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values());
fileDataStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values());
mChildren = updatedFiles;
}
@ -817,7 +835,7 @@ public class RefreshFolderOperation extends RemoteOperation {
shares.add(share);
}
}
mStorageManager.saveSharesInFolder(shares, mLocalFolder);
fileDataStorageManager.saveSharesInFolder(shares, mLocalFolder);
}
return result;

View file

@ -9,12 +9,18 @@
*/
package com.owncloud.android.ui.activity
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager
import com.nextcloud.client.jobs.operation.FileOperationHelper
import com.nextcloud.client.jobs.upload.FileUploadHelper
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.jobs.upload.UploadNotificationManager
@ -22,7 +28,6 @@ import com.nextcloud.model.HTTPStatusCodes
import com.nextcloud.utils.extensions.getParcelableArgument
import com.nextcloud.utils.extensions.logFileSize
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
@ -34,24 +39,26 @@ import com.owncloud.android.ui.dialog.ConflictsResolveDialog
import com.owncloud.android.ui.dialog.ConflictsResolveDialog.Decision
import com.owncloud.android.ui.dialog.ConflictsResolveDialog.OnConflictDecisionMadeListener
import com.owncloud.android.utils.FileStorageUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Wrapper activity which will be launched if keep-in-sync file will be modified by external application.
*/
class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener {
@JvmField
@Inject
var uploadsStorageManager: UploadsStorageManager? = null
lateinit var uploadsStorageManager: UploadsStorageManager
@JvmField
@Inject
var fileStorageManager: FileDataStorageManager? = null
lateinit var fileOperationHelper: FileOperationHelper
private var conflictUploadId: Long = 0
private var offlineOperationPath: String? = null
private var existingFile: OCFile? = null
private var newFile: OCFile? = null
private var localBehaviour = FileUploadWorker.LOCAL_BEHAVIOUR_FORGET
private lateinit var offlineOperationNotificationManager: OfflineOperationsNotificationManager
@JvmField
var listener: OnConflictDecisionMadeListener? = null
@ -61,7 +68,7 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
getArguments(savedInstanceState)
val upload = uploadsStorageManager?.getUploadById(conflictUploadId)
val upload = uploadsStorageManager.getUploadById(conflictUploadId)
if (upload != null) {
localBehaviour = upload.localAction
}
@ -69,6 +76,7 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
// new file was modified locally in file system
newFile = file
setupOnConflictDecisionMadeListener(upload)
offlineOperationNotificationManager = OfflineOperationsNotificationManager(this, viewThemeUtils)
}
private fun getArguments(savedInstanceState: Bundle?) {
@ -76,7 +84,9 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
conflictUploadId = savedInstanceState.getLong(EXTRA_CONFLICT_UPLOAD_ID)
existingFile = savedInstanceState.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java)
localBehaviour = savedInstanceState.getInt(EXTRA_LOCAL_BEHAVIOUR)
offlineOperationPath = savedInstanceState.getString(EXTRA_OFFLINE_OPERATION_PATH)
} else {
offlineOperationPath = intent.getStringExtra(EXTRA_OFFLINE_OPERATION_PATH)
conflictUploadId = intent.getLongExtra(EXTRA_CONFLICT_UPLOAD_ID, -1)
existingFile = intent.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java)
localBehaviour = intent.getIntExtra(EXTRA_LOCAL_BEHAVIOUR, localBehaviour)
@ -85,69 +95,121 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
private fun setupOnConflictDecisionMadeListener(upload: OCUpload?) {
listener = OnConflictDecisionMadeListener { decision: Decision? ->
val file = newFile // local file got changed, so either upload it or replace it again by server
// local file got changed, so either upload it or replace it again by server
val file = newFile
// version
val user = user.orElseThrow { RuntimeException() }
when (decision) {
Decision.CANCEL -> {}
Decision.KEEP_LOCAL -> {
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
}
FileUploadHelper.instance().uploadUpdatedFile(
user,
arrayOf(file),
localBehaviour,
NameCollisionPolicy.OVERWRITE
)
}
Decision.KEEP_BOTH -> {
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
}
FileUploadHelper.instance().uploadUpdatedFile(
user,
arrayOf(file),
localBehaviour,
NameCollisionPolicy.RENAME
)
}
Decision.KEEP_SERVER -> {
if (!shouldDeleteLocal()) {
// Overwrite local file
file?.let {
FileDownloadHelper.instance().downloadFile(
getUser().orElseThrow { RuntimeException() },
file,
conflictUploadId = conflictUploadId
)
}
}
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
UploadNotificationManager(
applicationContext,
viewThemeUtils
).dismissOldErrorNotification(it.remotePath, it.localPath)
}
}
else -> {}
val offlineOperation = if (offlineOperationPath != null) {
fileDataStorageManager.offlineOperationDao.getByPath(offlineOperationPath!!)
} else {
null
}
when (decision) {
Decision.KEEP_LOCAL -> keepLocal(file, upload, user)
Decision.KEEP_BOTH -> keepBoth(file, upload, user)
Decision.KEEP_SERVER -> keepServer(file, upload)
Decision.KEEP_OFFLINE_FOLDER -> keepOfflineFolder(newFile, offlineOperation)
Decision.KEEP_SERVER_FOLDER -> keepServerFile(offlineOperation)
Decision.KEEP_BOTH_FOLDER -> keepBothFolder(offlineOperation, newFile)
else -> Unit
}
finish()
}
}
private fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) {
offlineOperation ?: return
fileDataStorageManager.keepOfflineOperationAndServerFile(offlineOperation, serverFile)
backgroundJobManager.startOfflineOperations()
offlineOperationNotificationManager.dismissNotification(offlineOperation.id)
}
private fun keepServerFile(offlineOperation: OfflineOperationEntity?) {
offlineOperation ?: return
fileDataStorageManager.offlineOperationDao.delete(offlineOperation)
val id = offlineOperation.id ?: return
offlineOperationNotificationManager.dismissNotification(id)
}
private fun keepOfflineFolder(serverFile: OCFile?, offlineOperation: OfflineOperationEntity?) {
serverFile ?: return
offlineOperation ?: return
lifecycleScope.launch(Dispatchers.IO) {
val isSuccess = fileOperationHelper.removeFile(serverFile, false, false)
if (isSuccess) {
backgroundJobManager.startOfflineOperations()
launch(Dispatchers.Main) {
offlineOperationNotificationManager.dismissNotification(offlineOperation.id)
}
}
}
}
private fun keepLocal(file: OCFile?, upload: OCUpload?, user: User) {
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
}
FileUploadHelper.instance().uploadUpdatedFile(
user,
arrayOf(file),
localBehaviour,
NameCollisionPolicy.OVERWRITE
)
}
private fun keepBoth(file: OCFile?, upload: OCUpload?, user: User) {
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
}
FileUploadHelper.instance().uploadUpdatedFile(
user,
arrayOf(file),
localBehaviour,
NameCollisionPolicy.RENAME
)
}
private fun keepServer(file: OCFile?, upload: OCUpload?) {
if (!shouldDeleteLocal()) {
// Overwrite local file
file?.let {
FileDownloadHelper.instance().downloadFile(
user.orElseThrow { RuntimeException() },
file,
conflictUploadId = conflictUploadId
)
}
}
upload?.let {
FileUploadHelper.instance().removeFileUpload(it.remotePath, it.accountName)
UploadNotificationManager(
applicationContext,
viewThemeUtils
).dismissOldErrorNotification(it.remotePath, it.localPath)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
existingFile.logFileSize(TAG)
outState.putLong(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId)
outState.putParcelable(EXTRA_EXISTING_FILE, existingFile)
outState.putInt(EXTRA_LOCAL_BEHAVIOUR, localBehaviour)
outState.run {
putLong(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId)
putParcelable(EXTRA_EXISTING_FILE, existingFile)
putInt(EXTRA_LOCAL_BEHAVIOUR, localBehaviour)
}
}
override fun conflictDecisionMade(decision: Decision?) {
@ -157,23 +219,46 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
@Suppress("ReturnCount")
override fun onStart() {
super.onStart()
if (account == null) {
finish()
return
}
if (newFile == null) {
Log_OC.e(TAG, "No file received")
finish()
return
}
offlineOperationPath?.let { path ->
newFile?.let { ocFile ->
val offlineOperation = fileDataStorageManager.offlineOperationDao.getByPath(path)
if (offlineOperation == null) {
showErrorAndFinish()
return
}
val (ft, _) = prepareDialog()
val dialog = ConflictsResolveDialog.newInstance(
this,
offlineOperation,
ocFile
)
dialog.show(ft, "conflictDialog")
return
}
}
if (existingFile == null) {
val remotePath = fileStorageManager?.retrieveRemotePathConsideringEncryption(newFile) ?: return
val remotePath = fileDataStorageManager.retrieveRemotePathConsideringEncryption(newFile) ?: return
val operation = ReadFileRemoteOperation(remotePath)
@Suppress("TooGenericExceptionCaught")
Thread {
lifecycleScope.launch(Dispatchers.IO) {
try {
val result = operation.execute(account, this)
val result = operation.execute(account, this@ConflictsResolveActivity)
if (result.isSuccess) {
existingFile = FileStorageUtils.fillOCFile(result.data[0] as RemoteFile)
existingFile?.lastSyncDateForProperties = System.currentTimeMillis()
@ -186,14 +271,15 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
Log_OC.e(TAG, "Error when trying to fetch remote file", e)
showErrorAndFinish()
}
}.start()
}
} else {
val remotePath = fileStorageManager?.retrieveRemotePathConsideringEncryption(existingFile) ?: return
val remotePath = fileDataStorageManager.retrieveRemotePathConsideringEncryption(existingFile) ?: return
startDialog(remotePath)
}
}
private fun startDialog(remotePath: String) {
@SuppressLint("CommitTransaction")
private fun prepareDialog(): Pair<FragmentTransaction, User> {
val userOptional = user
if (!userOptional.isPresent) {
Log_OC.e(TAG, "User not present")
@ -206,13 +292,21 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
if (prev != null) {
fragmentTransaction.remove(prev)
}
return fragmentTransaction to user.get()
}
private fun startDialog(remotePath: String) {
val (ft, user) = prepareDialog()
if (existingFile != null && storageManager.fileExists(remotePath) && newFile != null) {
val dialog = ConflictsResolveDialog.newInstance(
existingFile,
this,
newFile!!,
userOptional.get()
existingFile!!,
user
)
dialog.show(fragmentTransaction, "conflictDialog")
dialog.show(ft, "conflictDialog")
} else {
// Account was changed to a different one - just finish
Log_OC.e(TAG, "Account was changed, finishing")
@ -222,8 +316,8 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
private fun showErrorAndFinish(code: Int? = null) {
val message = parseErrorMessage(code)
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
lifecycleScope.launch(Dispatchers.Main) {
Toast.makeText(this@ConflictsResolveActivity, message, Toast.LENGTH_LONG).show()
finish()
}
}
@ -254,18 +348,28 @@ class ConflictsResolveActivity : FileActivity(), OnConflictDecisionMadeListener
*/
const val EXTRA_LOCAL_BEHAVIOUR = "LOCAL_BEHAVIOUR"
const val EXTRA_EXISTING_FILE = "EXISTING_FILE"
private const val EXTRA_OFFLINE_OPERATION_PATH = "EXTRA_OFFLINE_OPERATION_PATH"
private val TAG = ConflictsResolveActivity::class.java.simpleName
@JvmStatic
fun createIntent(file: OCFile?, user: User?, conflictUploadId: Long, flag: Int?, context: Context?): Intent {
val intent = Intent(context, ConflictsResolveActivity::class.java)
if (flag != null) {
intent.flags = intent.flags or flag
return Intent(context, ConflictsResolveActivity::class.java).apply {
if (flag != null) {
flags = flags or flag
}
putExtra(EXTRA_FILE, file)
putExtra(EXTRA_USER, user)
putExtra(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId)
}
}
@JvmStatic
fun createIntent(file: OCFile, offlineOperationPath: String, context: Context): Intent {
return Intent(context, ConflictsResolveActivity::class.java).apply {
putExtra(EXTRA_FILE, file)
putExtra(EXTRA_OFFLINE_OPERATION_PATH, offlineOperationPath)
}
intent.putExtra(EXTRA_FILE, file)
intent.putExtra(EXTRA_USER, user)
intent.putExtra(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId)
return intent
}
}
}

View file

@ -21,8 +21,10 @@ import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.net.ConnectivityManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
@ -36,6 +38,8 @@ import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.client.jobs.download.FileDownloadWorker;
import com.nextcloud.client.jobs.upload.FileUploadHelper;
import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.receiver.NetworkChangeListener;
import com.nextcloud.receiver.NetworkChangeReceiver;
import com.nextcloud.utils.EditorUtils;
import com.nextcloud.utils.extensions.ActivityExtensionsKt;
import com.nextcloud.utils.extensions.BundleExtensionsKt;
@ -112,7 +116,7 @@ import static com.owncloud.android.ui.activity.FileDisplayActivity.TAG_PUBLIC_LI
*/
public abstract class FileActivity extends DrawerActivity
implements OnRemoteOperationListener, ComponentsGetter, SslUntrustedCertDialog.OnSslUntrustedCertListener,
LoadingVersionNumberTask.VersionDevInterface, FileDetailSharingFragment.OnEditShareListener {
LoadingVersionNumberTask.VersionDevInterface, FileDetailSharingFragment.OnEditShareListener, NetworkChangeListener {
public static final String EXTRA_FILE = "com.owncloud.android.ui.activity.FILE";
public static final String EXTRA_LIVE_PHOTO_FILE = "com.owncloud.android.ui.activity.LIVE.PHOTO.FILE";
@ -176,6 +180,13 @@ public abstract class FileActivity extends DrawerActivity
@Inject
ArbitraryDataProvider arbitraryDataProvider;
private NetworkChangeReceiver networkChangeReceiver;
private void registerNetworkChangeReceiver() {
IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
registerReceiver(networkChangeReceiver, filter);
}
@Override
public void showFiles(boolean onDeviceOnly, boolean personalFiles) {
// must be specialized in subclasses
@ -198,10 +209,11 @@ public abstract class FileActivity extends DrawerActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService);
usersAndGroupsSearchConfig.reset();
mHandler = new Handler();
mFileOperationsHelper = new FileOperationsHelper(this, getUserAccountManager(), connectivityService, editorUtils);
User user = null;
User user;
if (savedInstanceState != null) {
mFile = BundleExtensionsKt.getParcelableArgument(savedInstanceState, FileActivity.EXTRA_FILE, OCFile.class);
@ -227,11 +239,15 @@ public abstract class FileActivity extends DrawerActivity
mOperationsServiceConnection = new OperationsServiceConnection();
bindService(new Intent(this, OperationsService.class), mOperationsServiceConnection,
Context.BIND_AUTO_CREATE);
registerNetworkChangeReceiver();
}
public void checkInternetConnection() {
if (connectivityService != null && connectivityService.isConnected()) {
@Override
public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailable) {
if (isNetworkAndServerAvailable) {
hideInfoBox();
} else {
showInfoBox(R.string.offline_mode);
}
}
@ -266,6 +282,8 @@ public abstract class FileActivity extends DrawerActivity
mOperationsServiceBinder = null;
}
unregisterReceiver(networkChangeReceiver);
super.onDestroy();
}

View file

@ -93,6 +93,7 @@ import com.owncloud.android.operations.RenameFileOperation;
import com.owncloud.android.operations.SynchronizeFileOperation;
import com.owncloud.android.operations.UploadFileOperation;
import com.owncloud.android.syncadapter.FileSyncAdapter;
import com.owncloud.android.ui.activity.fileDisplayActivity.OfflineFolderConflictManager;
import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask;
import com.owncloud.android.ui.asynctasks.FetchRemoteFileTask;
import com.owncloud.android.ui.asynctasks.GetRemoteFileTask;
@ -171,6 +172,8 @@ public class FileDisplayActivity extends FileActivity
public static final String LIST_GROUPFOLDERS = "LIST_GROUPFOLDERS";
public static final int SINGLE_USER_SIZE = 1;
public static final String OPEN_FILE = "NC_OPEN_FILE";
public static final String FOLDER_SYNC_CONFLICT = "FOLDER_SYNC_CONFLICT";
public static final String FOLDER_SYNC_CONFLICT_ARG_REMOTE_IDS_TO_OPERATION_PATHS = "FOLDER_SYNC_CONFLICT_ARG_REMOTE_IDS_TO_OPERATION_PATHS";
private FilesBinding binding;
@ -278,6 +281,9 @@ public class FileDisplayActivity extends FileActivity
initSyncBroadcastReceiver();
observeWorkerState();
registerRefreshFolderEventReceiver();
OfflineFolderConflictManager offlineFolderConflictManager = new OfflineFolderConflictManager(this);
offlineFolderConflictManager.registerRefreshSearchEventReceiver();
}
@SuppressWarnings("unchecked")
@ -871,7 +877,7 @@ public class FileDisplayActivity extends FileActivity
if (hasEnoughSpaceAvailable) {
File file = new File(filesToUpload[0]);
File renamedFile;
if(requestCode == REQUEST_CODE__UPLOAD_FROM_CAMERA) {
if (requestCode == REQUEST_CODE__UPLOAD_FROM_CAMERA) {
renamedFile = new File(file.getParent() + PATH_SEPARATOR + FileOperationsHelper.getCapturedImageName());
} else {
renamedFile = new File(file.getParent() + PATH_SEPARATOR + FileOperationsHelper.getCapturedVideoName());
@ -1315,7 +1321,6 @@ public class FileDisplayActivity extends FileActivity
Log_OC.d(TAG, "Setting progress visibility to " + mSyncInProgress);
OCFileListFragment ocFileListFragment = getListOfFilesFragment();
if (ocFileListFragment != null) {
ocFileListFragment.setLoading(mSyncInProgress);
@ -1597,10 +1602,24 @@ public class FileDisplayActivity extends FileActivity
fileDownloadProgressListener = null;
} else if (state instanceof WorkerState.UploadFinished) {
refreshList();
} else if (state instanceof WorkerState.OfflineOperationsCompleted) {
refreshCurrentDirectory();
}
});
}
public void refreshCurrentDirectory() {
OCFile currentDir = (getCurrentDir() != null) ?
getStorageManager().getFileByDecryptedRemotePath(getCurrentDir().getRemotePath()) : null;
OCFileListFragment fileListFragment =
(ActivityExtensionsKt.lastFragment(this) instanceof OCFileListFragment fragment) ? fragment : getListOfFilesFragment();
if (fileListFragment != null) {
fileListFragment.listDirectory(currentDir, false, false);
}
}
private void handleDownloadWorkerState() {
if (mWaitingToPreview != null && getStorageManager() != null) {
mWaitingToPreview = getStorageManager().getFileById(mWaitingToPreview.getFileId());
@ -2216,7 +2235,7 @@ public class FileDisplayActivity extends FileActivity
syncAndUpdateFolder(true);
}
private void syncAndUpdateFolder(boolean ignoreETag) {
public void syncAndUpdateFolder(boolean ignoreETag) {
syncAndUpdateFolder(ignoreETag, false);
}

View file

@ -215,15 +215,19 @@ public abstract class ToolbarActivity extends BaseActivity implements Injectable
* @param text the text to be displayed
*/
protected final void showInfoBox(@StringRes int text) {
mInfoBox.setVisibility(View.VISIBLE);
mInfoBoxMessage.setText(text);
if (mInfoBox != null && mInfoBoxMessage != null) {
mInfoBox.setVisibility(View.VISIBLE);
mInfoBoxMessage.setText(text);
}
}
/**
* Hides the toolbar's info box.
*/
public final void hideInfoBox() {
mInfoBox.setVisibility(View.GONE);
if (mInfoBox != null) {
mInfoBox.setVisibility(View.GONE);
}
}
public void setPreviewImageVisibility(boolean isVisibility) {

View file

@ -0,0 +1,55 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.owncloud.android.ui.activity.fileDisplayActivity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsNotificationManager
import com.nextcloud.utils.extensions.getSerializableArgument
import com.owncloud.android.ui.activity.FileDisplayActivity
class OfflineFolderConflictManager(private val activity: FileDisplayActivity) {
private val notificationManager = OfflineOperationsNotificationManager(activity, activity.viewThemeUtils)
fun registerRefreshSearchEventReceiver() {
val filter = IntentFilter(FileDisplayActivity.FOLDER_SYNC_CONFLICT)
LocalBroadcastManager.getInstance(activity).registerReceiver(folderSyncConflictEventReceiver, filter)
}
private val folderSyncConflictEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
intent.run {
@Suppress("UNCHECKED_CAST")
val map = getSerializableArgument(
FileDisplayActivity.FOLDER_SYNC_CONFLICT_ARG_REMOTE_IDS_TO_OPERATION_PATHS,
HashMap::class.java
) as? HashMap<String, String>
if (!map.isNullOrEmpty()) {
showFolderSyncConflictNotifications(map)
}
}
}
}
private fun showFolderSyncConflictNotifications(remoteIdsToOperationPaths: HashMap<String, String>) {
remoteIdsToOperationPaths.forEach { (remoteId, path) ->
val file = activity.storageManager.getFileByRemoteId(remoteId)
file?.let {
val entity = activity.storageManager.offlineOperationDao.getByPath(path)
if (activity.user.isPresent) {
notificationManager.showConflictResolveNotification(file, entity, activity.user.get())
}
}
}
}
}

View file

@ -9,6 +9,6 @@ package com.owncloud.android.ui.adapter
import android.widget.TextView
internal interface ListGridItemViewHolder : ListGridImageViewHolder {
internal interface ListGridItemViewHolder : ListViewHolder {
val fileName: TextView
}

View file

@ -14,7 +14,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.elyeproj.loaderviewlibrary.LoaderImageView
interface ListGridImageViewHolder {
interface ListViewHolder {
val thumbnail: ImageView
fun showVideoOverlay()
val shimmerThumbnail: LoaderImageView

View file

@ -16,6 +16,8 @@ import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentValues;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
@ -23,7 +25,6 @@ import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
@ -32,6 +33,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole;
import com.nextcloud.client.account.User;
import com.nextcloud.client.jobs.upload.FileUploadHelper;
import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.utils.extensions.ViewExtensionsKt;
import com.owncloud.android.MainApp;
import com.owncloud.android.R;
import com.owncloud.android.databinding.GridImageBinding;
@ -96,25 +98,26 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
private static final int showFilenameColumnThreshold = 4;
private final String userId;
private Activity activity;
private AppPreferences preferences;
private final Activity activity;
private final AppPreferences preferences;
private List<OCFile> mFiles = new ArrayList<>();
private List<OCFile> mFilesAll = new ArrayList<>();
private boolean hideItemOptions;
private final List<OCFile> mFilesAll = new ArrayList<>();
private final boolean hideItemOptions;
private long lastTimestamp;
private boolean gridView;
public ArrayList<String> listOfHiddenFiles = new ArrayList<>();
private FileDataStorageManager mStorageManager;
private User user;
private OCFileListFragmentInterface ocFileListFragmentInterface;
private final OCFileListFragmentInterface ocFileListFragmentInterface;
private OCFile currentDirectory;
private static final String TAG = OCFileListAdapter.class.getSimpleName();
private static final int VIEWTYPE_FOOTER = 0;
private static final int VIEWTYPE_ITEM = 1;
private static final int VIEWTYPE_IMAGE = 2;
private static final int VIEWTYPE_HEADER = 3;
private static final int VIEW_TYPE_FOOTER = 0;
private static final int VIEW_TYPE_ITEM = 1;
private static final int VIEW_TYPE_IMAGE = 2;
private static final int VIEW_TYPE_HEADER = 3;
private boolean onlyOnDevice;
private final OCFileListDelegate ocFileListDelegate;
@ -339,23 +342,23 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
@Override
public int getItemViewType(int position) {
if (shouldShowHeader() && position == 0) {
return VIEWTYPE_HEADER;
return VIEW_TYPE_HEADER;
}
if (shouldShowHeader() && position == mFiles.size() + 1 ||
(!shouldShowHeader() && position == mFiles.size())) {
return VIEWTYPE_FOOTER;
return VIEW_TYPE_FOOTER;
}
OCFile item = getItem(position);
if (item == null) {
return VIEWTYPE_ITEM;
return VIEW_TYPE_ITEM;
}
if (MimeTypeUtil.isImageOrVideo(item)) {
return VIEWTYPE_IMAGE;
return VIEW_TYPE_IMAGE;
} else {
return VIEWTYPE_ITEM;
return VIEW_TYPE_ITEM;
}
}
@ -378,9 +381,9 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
);
}
}
case VIEWTYPE_IMAGE -> {
case VIEW_TYPE_IMAGE -> {
if (gridView) {
return new OCFileListGridImageViewHolder(
return new OCFileListViewHolder(
GridImageBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)
);
} else {
@ -389,12 +392,12 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
);
}
}
case VIEWTYPE_FOOTER -> {
case VIEW_TYPE_FOOTER -> {
return new OCFileListFooterViewHolder(
ListFooterBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)
);
}
case VIEWTYPE_HEADER -> {
case VIEW_TYPE_HEADER -> {
ListHeaderBinding binding = ListHeaderBinding.inflate(
LayoutInflater.from(parent.getContext()),
parent,
@ -421,7 +424,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
PreviewTextFragment.setText(headerViewHolder.getHeaderText(), text, null, activity, true, true, viewThemeUtils);
headerViewHolder.getHeaderView().setOnClickListener(v -> ocFileListFragmentInterface.onHeaderClicked());
} else {
ListGridImageViewHolder gridViewHolder = (ListGridImageViewHolder) holder;
ListViewHolder gridViewHolder = (ListViewHolder) holder;
OCFile file = getItem(position);
if (file == null) {
@ -430,24 +433,24 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
}
ocFileListDelegate.bindGridViewHolder(gridViewHolder, file, currentDirectory, searchType);
checkVisibilityOfMoreButtons(gridViewHolder);
ViewExtensionsKt.setVisibleIf(gridViewHolder.getMore(), !isMultiSelect());
checkVisibilityOfFileFeaturesLayout(gridViewHolder);
if (holder instanceof ListItemViewHolder) {
bindListItemViewHolder((ListItemViewHolder) gridViewHolder, file);
if (holder instanceof ListItemViewHolder itemViewHolder) {
bindListItemViewHolder(itemViewHolder, file);
}
if (holder instanceof ListGridItemViewHolder) {
bindListGridItemViewHolder((ListGridItemViewHolder) holder, file);
checkVisibilityOfMoreButtons((ListGridItemViewHolder) holder);
checkVisibilityOfFileFeaturesLayout((ListGridItemViewHolder) holder);
if (holder instanceof ListGridItemViewHolder gridItemViewHolder) {
bindListGridItemViewHolder(gridItemViewHolder, file);
ViewExtensionsKt.setVisibleIf(gridItemViewHolder.getMore(), !isMultiSelect());
checkVisibilityOfFileFeaturesLayout(gridItemViewHolder);
}
updateLivePhotoIndicators(gridViewHolder, file);
}
}
private void checkVisibilityOfFileFeaturesLayout(ListGridImageViewHolder holder) {
private void checkVisibilityOfFileFeaturesLayout(ListViewHolder holder) {
int fileFeaturesVisibility = View.GONE;
LinearLayout fileFeaturesLayout = holder.getFileFeaturesLayout();
@ -465,19 +468,6 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
fileFeaturesLayout.setVisibility(fileFeaturesVisibility);
}
private void checkVisibilityOfMoreButtons(ListGridImageViewHolder holder) {
ImageButton moreButton = holder.getMore();
if (moreButton == null) {
return;
}
if (isMultiSelect()) {
moreButton.setVisibility(View.GONE);
} else {
moreButton.setVisibility(View.VISIBLE);
}
}
private void mergeOCFilesForLivePhoto() {
List<OCFile> filesToRemove = new ArrayList<>();
@ -505,13 +495,13 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
filesToRemove.clear();
}
private void updateLivePhotoIndicators(ListGridImageViewHolder holder, OCFile file) {
private void updateLivePhotoIndicators(ListViewHolder holder, OCFile file) {
boolean isLivePhoto = file.getLinkedFileIdForLivePhoto() != null;
if (holder instanceof OCFileListItemViewHolder) {
holder.getLivePhotoIndicator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE));
holder.getLivePhotoIndicatorSeparator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE));
} else if (holder instanceof OCFileListGridImageViewHolder) {
} else if (holder instanceof OCFileListViewHolder) {
holder.getGridLivePhotoIndicator().setVisibility(isLivePhoto ? (View.VISIBLE) : (View.GONE));
}
}
@ -529,6 +519,9 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
holder.getFileName().setVisibility(View.VISIBLE);
}
}
ViewExtensionsKt.setVisibleIf(holder.getShared(), !file.isOfflineOperation());
setColorFilterForOfflineOperations(holder, file);
}
private void bindListItemViewHolder(ListItemViewHolder holder, OCFile file) {
@ -599,15 +592,27 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
localSize = localFile.length();
}
holder.getFileSize().setText(DisplayUtils.bytesToHumanReadable(localSize));
holder.getFileSize().setVisibility(View.VISIBLE);
holder.getFileSizeSeparator().setVisibility(View.VISIBLE);
if (file.isOfflineOperation()) {
holder.getFileSize().setText(MainApp.string(R.string.oc_file_list_adapter_offline_operation_description_text));
holder.getFileSizeSeparator().setVisibility(View.GONE);
} else {
holder.getFileSize().setText(DisplayUtils.bytesToHumanReadable(localSize));
holder.getFileSizeSeparator().setVisibility(View.VISIBLE);
}
} else {
final long fileLength = file.getFileLength();
if (fileLength >= 0) {
holder.getFileSize().setText(DisplayUtils.bytesToHumanReadable(fileLength));
holder.getFileSize().setVisibility(View.VISIBLE);
holder.getFileSizeSeparator().setVisibility(View.VISIBLE);
if (file.isOfflineOperation()) {
holder.getFileSize().setText(MainApp.string(R.string.oc_file_list_adapter_offline_operation_description_text));
holder.getFileSizeSeparator().setVisibility(View.GONE);
} else {
holder.getFileSize().setText(DisplayUtils.bytesToHumanReadable(fileLength));
holder.getFileSizeSeparator().setVisibility(View.VISIBLE);
}
} else {
holder.getFileSize().setVisibility(View.GONE);
holder.getFileSizeSeparator().setVisibility(View.GONE);
@ -641,12 +646,32 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
} else {
holder.getOverflowMenu().setImageResource(R.drawable.ic_dots_vertical);
}
applyVisualsForOfflineOperations(holder, file);
}
private void applyVisualsForOfflineOperations(ListItemViewHolder holder, OCFile file) {
ViewExtensionsKt.setVisibleIf(holder.getShared(), !file.isOfflineOperation());
setColorFilterForOfflineOperations(holder, file);
}
private void setColorFilterForOfflineOperations(ListViewHolder holder, OCFile file) {
if (!file.isFolder()) {
return;
}
if (file.isOfflineOperation()) {
holder.getThumbnail().setColorFilter(Color.GRAY, PorterDuff.Mode.SRC_IN);
} else {
Drawable drawable = viewThemeUtils.platform.tintDrawable(MainApp.getAppContext(), holder.getThumbnail().getDrawable(), ColorRole.PRIMARY);
holder.getThumbnail().setImageDrawable(drawable);
}
}
@Override
public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof ListGridImageViewHolder) {
LoaderImageView thumbnailShimmer = ((ListGridImageViewHolder) holder).getShimmerThumbnail();
if (holder instanceof ListViewHolder) {
LoaderImageView thumbnailShimmer = ((ListViewHolder) holder).getShimmerThumbnail();
if (thumbnailShimmer.getVisibility() == View.VISIBLE) {
thumbnailShimmer.setImageResource(R.drawable.background);
thumbnailShimmer.resetLoader();
@ -1018,7 +1043,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
super.onViewRecycled(holder);
if (holder instanceof ListGridImageViewHolder listGridImageViewHolder) {
if (holder instanceof ListViewHolder listGridImageViewHolder) {
LoaderImageView thumbnailShimmer = listGridImageViewHolder.getShimmerThumbnail();
DisplayUtils.stopShimmer(thumbnailShimmer, listGridImageViewHolder.getThumbnail());
}

View file

@ -195,7 +195,7 @@ class OCFileListDelegate(
}
fun bindGridViewHolder(
gridViewHolder: ListGridImageViewHolder,
gridViewHolder: ListViewHolder,
file: OCFile,
currentDirectory: OCFile?,
searchType: SearchType?
@ -253,7 +253,7 @@ class OCFileListDelegate(
}
}
private fun bindUnreadComments(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun bindUnreadComments(file: OCFile, gridViewHolder: ListViewHolder) {
if (file.unreadCommentsCount > 0) {
gridViewHolder.unreadComments.visibility = View.VISIBLE
gridViewHolder.unreadComments.setOnClickListener {
@ -265,7 +265,7 @@ class OCFileListDelegate(
}
}
private fun bindGridItemLayout(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun bindGridItemLayout(file: OCFile, gridViewHolder: ListViewHolder) {
setItemLayoutBackgroundColor(file, gridViewHolder)
setCheckBoxImage(file, gridViewHolder)
setItemLayoutOnClickListeners(file, gridViewHolder)
@ -275,7 +275,7 @@ class OCFileListDelegate(
}
}
private fun setItemLayoutOnClickListeners(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun setItemLayoutOnClickListeners(file: OCFile, gridViewHolder: ListViewHolder) {
gridViewHolder.itemLayout.setOnClickListener { ocFileListFragmentInterface.onItemClicked(file) }
if (!hideItemOptions) {
@ -290,7 +290,7 @@ class OCFileListDelegate(
}
}
private fun setItemLayoutBackgroundColor(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun setItemLayoutBackgroundColor(file: OCFile, gridViewHolder: ListViewHolder) {
val cornerRadius = context.resources.getDimension(R.dimen.selected_grid_container_radius)
val isDarkModeActive = (syncFolderProvider?.preferences?.isDarkModeEnabled == true)
@ -313,7 +313,7 @@ class OCFileListDelegate(
}
}
private fun setCheckBoxImage(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun setCheckBoxImage(file: OCFile, gridViewHolder: ListViewHolder) {
if (isCheckedFile(file)) {
gridViewHolder.checkbox.setImageDrawable(
viewThemeUtils.platform.tintDrawable(context, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY)
@ -323,7 +323,7 @@ class OCFileListDelegate(
}
}
private fun bindGridMetadataViews(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun bindGridMetadataViews(file: OCFile, gridViewHolder: ListViewHolder) {
if (showMetadata) {
showLocalFileIndicator(file, gridViewHolder)
gridViewHolder.favorite.visibility = if (file.isFavorite) View.VISIBLE else View.GONE
@ -333,7 +333,7 @@ class OCFileListDelegate(
}
}
private fun showLocalFileIndicator(file: OCFile, gridViewHolder: ListGridImageViewHolder) {
private fun showLocalFileIndicator(file: OCFile, gridViewHolder: ListViewHolder) {
val operationsServiceBinder = transferServiceGetter.operationsServiceBinder
val icon: Int? = when {
@ -365,7 +365,7 @@ class OCFileListDelegate(
}
}
private fun showShareIcon(gridViewHolder: ListGridImageViewHolder, file: OCFile) {
private fun showShareIcon(gridViewHolder: ListViewHolder, file: OCFile) {
val sharedIconView = gridViewHolder.shared
if (gridViewHolder is OCFileListItemViewHolder || file.unreadCommentsCount == 0) {
sharedIconView.visibility = View.VISIBLE

View file

@ -16,11 +16,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.elyeproj.loaderviewlibrary.LoaderImageView
import com.owncloud.android.databinding.GridImageBinding
internal class OCFileListGridImageViewHolder(var binding: GridImageBinding) :
internal class OCFileListViewHolder(var binding: GridImageBinding) :
RecyclerView.ViewHolder(
binding.root
),
ListGridImageViewHolder {
ListViewHolder {
override val thumbnail: ImageView
get() = binding.thumbnail

View file

@ -1,11 +1,8 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2023 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2020-2022 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2015 ownCloud Inc.
* SPDX-FileCopyrightText: 2012 Bartosz Przybylski <bart.p.pl@gmail.com>
* SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.owncloud.android.ui.dialog
@ -21,10 +18,10 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.OfflineOperationEntity
import com.nextcloud.client.di.Injectable
import com.nextcloud.utils.extensions.getParcelableArgument
import com.nextcloud.utils.extensions.getSerializableArgument
import com.nextcloud.utils.extensions.logFileSize
import com.owncloud.android.R
import com.owncloud.android.databinding.ConflictResolveDialogBinding
import com.owncloud.android.datamodel.FileDataStorageManager
@ -33,7 +30,10 @@ import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.ThumbnailsCacheManager.ThumbnailGenerationTask
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.adapter.LocalFileListAdapter
import com.owncloud.android.ui.dialog.parcel.ConflictDialogData
import com.owncloud.android.ui.dialog.parcel.ConflictFileData
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.MimeTypeUtil
import com.owncloud.android.utils.theme.ViewThemeUtils
import java.io.File
import javax.inject.Inject
@ -44,24 +44,32 @@ import javax.inject.Inject
class ConflictsResolveDialog : DialogFragment(), Injectable {
private lateinit var binding: ConflictResolveDialogBinding
private var existingFile: OCFile? = null
private var newFile: File? = null
var listener: OnConflictDecisionMadeListener? = null
private var user: User? = null
private val asyncTasks: MutableList<ThumbnailGenerationTask> = ArrayList()
private var positiveButton: MaterialButton? = null
private var data: ConflictDialogData? = null
private var user: User? = null
private var leftDataFile: File? = null
private var rightDataFile: OCFile? = null
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var syncedFolderProvider: SyncedFolderProvider
@Inject
lateinit var fileDataStorageManager: FileDataStorageManager
enum class Decision {
CANCEL,
KEEP_BOTH,
KEEP_LOCAL,
KEEP_SERVER
KEEP_SERVER,
KEEP_OFFLINE_FOLDER,
KEEP_SERVER_FOLDER,
KEEP_BOTH_FOLDER
}
override fun onAttach(context: Context) {
@ -98,14 +106,13 @@ class ConflictsResolveDialog : DialogFragment(), Injectable {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
existingFile = savedInstanceState.getParcelableArgument(KEY_EXISTING_FILE, OCFile::class.java)
newFile = savedInstanceState.getSerializableArgument(KEY_NEW_FILE, File::class.java)
user = savedInstanceState.getParcelableArgument(KEY_USER, User::class.java)
} else if (arguments != null) {
existingFile = arguments.getParcelableArgument(KEY_EXISTING_FILE, OCFile::class.java)
newFile = arguments.getSerializableArgument(KEY_NEW_FILE, File::class.java)
user = arguments.getParcelableArgument(KEY_USER, User::class.java)
val bundle = savedInstanceState ?: arguments
if (bundle != null) {
data = bundle.getParcelableArgument(ARG_CONFLICT_DATA, ConflictDialogData::class.java)
leftDataFile = bundle.getSerializableArgument(ARG_LEFT_FILE, File::class.java)
rightDataFile = bundle.getParcelableArgument(ARG_RIGHT_FILE, OCFile::class.java)
user = bundle.getParcelableArgument(ARG_USER, User::class.java)
} else {
Toast.makeText(context, "Failed to create conflict dialog", Toast.LENGTH_LONG).show()
}
@ -113,80 +120,116 @@ class ConflictsResolveDialog : DialogFragment(), Injectable {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
existingFile.logFileSize(TAG)
newFile.logFileSize(TAG)
outState.putParcelable(KEY_EXISTING_FILE, existingFile)
outState.putSerializable(KEY_NEW_FILE, newFile)
outState.putParcelable(KEY_USER, user)
outState.run {
putParcelable(ARG_CONFLICT_DATA, data)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = ConflictResolveDialogBinding.inflate(requireActivity().layoutInflater)
viewThemeUtils.platform.themeCheckbox(binding.newCheckbox)
viewThemeUtils.platform.themeCheckbox(binding.existingCheckbox)
val builder = MaterialAlertDialogBuilder(requireActivity())
builder.setView(binding.root)
.setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int ->
if (binding.newCheckbox.isChecked && binding.existingCheckbox.isChecked) {
listener?.conflictDecisionMade(Decision.KEEP_BOTH)
} else if (binding.newCheckbox.isChecked) {
listener?.conflictDecisionMade(Decision.KEEP_LOCAL)
} else if (binding.existingCheckbox.isChecked) {
listener?.conflictDecisionMade(Decision.KEEP_SERVER)
}
}
.setNegativeButton(R.string.common_cancel) { _: DialogInterface?, _: Int ->
listener?.conflictDecisionMade(Decision.CANCEL)
}
.setTitle(String.format(getString(R.string.conflict_file_headline), existingFile?.fileName))
val builder = createDialogBuilder()
setupUI()
setOnClickListeners()
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.existingFileContainer.context, builder)
viewThemeUtils.run {
platform.themeCheckbox(binding.leftCheckbox)
platform.themeCheckbox(binding.rightCheckbox)
dialog.colorMaterialAlertDialogBackground(requireContext(), builder)
}
return builder.create()
}
private fun setupUI() {
val parentFile = existingFile?.remotePath?.let { File(it).parentFile }
if (parentFile != null) {
binding.`in`.text = String.format(getString(R.string.in_folder), parentFile.absolutePath)
} else {
binding.`in`.visibility = View.GONE
}
private fun createDialogBuilder(): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
.setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int ->
okButtonClick()
}
.setNegativeButton(R.string.common_cancel) { _: DialogInterface?, _: Int ->
listener?.conflictDecisionMade(Decision.CANCEL)
}
.setTitle(data?.folderName)
}
private fun okButtonClick() {
binding.run {
val isFolderNameNotExists = (data?.folderName == null)
val decision = when {
leftCheckbox.isChecked && rightCheckbox.isChecked ->
if (isFolderNameNotExists) Decision.KEEP_BOTH_FOLDER else Decision.KEEP_BOTH
leftCheckbox.isChecked ->
if (isFolderNameNotExists) Decision.KEEP_OFFLINE_FOLDER else Decision.KEEP_LOCAL
rightCheckbox.isChecked ->
if (isFolderNameNotExists) Decision.KEEP_SERVER_FOLDER else Decision.KEEP_SERVER
else -> null
}
decision?.let { listener?.conflictDecisionMade(it) }
}
}
private fun setupUI() {
binding.run {
data?.let {
val (leftData, rightData) = it.checkboxData
folderName.visibility = if (it.folderName == null) {
View.GONE
} else {
View.VISIBLE
}
folderName.text = it.folderName
title.visibility = if (it.title == null) {
View.GONE
} else {
View.VISIBLE
}
title.text = it.title
description.text = it.description
leftCheckbox.text = leftData.title
leftTimestamp.text = leftData.timestamp
leftFileSize.text = leftData.fileSize
rightCheckbox.text = rightData.title
rightTimestamp.text = rightData.timestamp
rightFileSize.text = rightData.fileSize
if (leftDataFile != null && rightDataFile != null && user != null) {
setThumbnailsForFileConflicts()
} else {
val folderIcon = MimeTypeUtil.getDefaultFolderIcon(requireContext(), viewThemeUtils)
leftThumbnail.setImageDrawable(folderIcon)
rightThumbnail.setImageDrawable(folderIcon)
}
}
}
}
private fun setThumbnailsForFileConflicts() {
binding.leftThumbnail.tag = leftDataFile.hashCode()
binding.rightThumbnail.tag = rightDataFile.hashCode()
// set info for new file
binding.newSize.text = newFile?.length()?.let { DisplayUtils.bytesToHumanReadable(it) }
binding.newTimestamp.text = newFile?.lastModified()?.let { DisplayUtils.getRelativeTimestamp(context, it) }
binding.newThumbnail.tag = newFile.hashCode()
LocalFileListAdapter.setThumbnail(
newFile,
binding.newThumbnail,
leftDataFile,
binding.leftThumbnail,
context,
viewThemeUtils
)
// set info for existing file
binding.existingSize.text = existingFile?.fileLength?.let { DisplayUtils.bytesToHumanReadable(it) }
binding.existingTimestamp.text = existingFile?.modificationTimestamp?.let {
DisplayUtils.getRelativeTimestamp(
context,
it
)
}
binding.existingThumbnail.tag = existingFile?.fileId
DisplayUtils.setThumbnail(
existingFile,
binding.existingThumbnail,
rightDataFile,
binding.rightThumbnail,
user,
FileDataStorageManager(
user,
requireContext().contentResolver
),
fileDataStorageManager,
asyncTasks,
false,
context,
@ -198,31 +241,35 @@ class ConflictsResolveDialog : DialogFragment(), Injectable {
}
private fun setOnClickListeners() {
val checkBoxClickListener = View.OnClickListener {
positiveButton?.isEnabled = binding.newCheckbox.isChecked || binding.existingCheckbox.isChecked
}
binding.run {
val checkBoxClickListener = View.OnClickListener {
positiveButton?.isEnabled = (leftCheckbox.isChecked || rightCheckbox.isChecked)
}
binding.newCheckbox.setOnClickListener(checkBoxClickListener)
binding.existingCheckbox.setOnClickListener(checkBoxClickListener)
leftCheckbox.setOnClickListener(checkBoxClickListener)
rightCheckbox.setOnClickListener(checkBoxClickListener)
binding.newFileContainer.setOnClickListener {
binding.newCheckbox.isChecked = !binding.newCheckbox.isChecked
positiveButton?.isEnabled = binding.newCheckbox.isChecked || binding.existingCheckbox.isChecked
}
binding.existingFileContainer.setOnClickListener {
binding.existingCheckbox.isChecked = !binding.existingCheckbox.isChecked
positiveButton?.isEnabled = binding.newCheckbox.isChecked || binding.existingCheckbox.isChecked
leftFileContainer.setOnClickListener {
leftCheckbox.toggle()
positiveButton?.isEnabled = (leftCheckbox.isChecked || rightCheckbox.isChecked)
}
rightFileContainer.setOnClickListener {
rightCheckbox.toggle()
positiveButton?.isEnabled = (leftCheckbox.isChecked || rightCheckbox.isChecked)
}
}
}
fun showDialog(activity: AppCompatActivity) {
val prev = activity.supportFragmentManager.findFragmentByTag("dialog")
val ft = activity.supportFragmentManager.beginTransaction()
if (prev != null) {
ft.remove(prev)
activity.supportFragmentManager.beginTransaction().run {
if (prev != null) {
this.remove(prev)
}
addToBackStack(null)
show(this, "dialog")
}
ft.addToBackStack(null)
show(ft, "dialog")
}
override fun onCancel(dialog: DialogInterface) {
@ -236,35 +283,103 @@ class ConflictsResolveDialog : DialogFragment(), Injectable {
override fun onStop() {
super.onStop()
for (task in asyncTasks) {
task.cancel(true)
asyncTasks.forEach {
it.cancel(true)
Log_OC.d(this, "cancel: abort get method directly")
task.getMethod?.abort()
it.getMethod?.abort()
}
asyncTasks.clear()
}
companion object {
private const val TAG = "ConflictsResolveDialog"
private const val KEY_NEW_FILE = "file"
private const val KEY_EXISTING_FILE = "ocfile"
private const val KEY_USER = "user"
private const val ARG_CONFLICT_DATA = "CONFLICT_DATA"
private const val ARG_LEFT_FILE = "LEFT_FILE"
private const val ARG_RIGHT_FILE = "RIGHT_FILE"
private const val ARG_USER = "USER"
@JvmStatic
fun newInstance(existingFile: OCFile?, newFile: OCFile, user: User?): ConflictsResolveDialog {
val file = File(newFile.storagePath)
file.logFileSize(TAG)
fun newInstance(context: Context, leftFile: OCFile, rightFile: OCFile, user: User?): ConflictsResolveDialog {
val file = File(leftFile.storagePath)
val conflictData = getFileConflictData(file, rightFile, context)
val bundle = Bundle().apply {
putParcelable(KEY_EXISTING_FILE, existingFile)
putSerializable(KEY_NEW_FILE, file)
putParcelable(KEY_USER, user)
putParcelable(ARG_CONFLICT_DATA, conflictData)
putSerializable(ARG_LEFT_FILE, file)
putParcelable(ARG_RIGHT_FILE, rightFile)
putParcelable(ARG_USER, user)
}
return ConflictsResolveDialog().apply {
arguments = bundle
}
}
@JvmStatic
fun newInstance(
context: Context,
offlineOperation: OfflineOperationEntity,
rightFile: OCFile
): ConflictsResolveDialog {
val conflictData = getFolderConflictData(offlineOperation, rightFile, context)
val bundle = Bundle().apply {
putParcelable(ARG_CONFLICT_DATA, conflictData)
putParcelable(ARG_RIGHT_FILE, rightFile)
}
return ConflictsResolveDialog().apply {
arguments = bundle
}
}
@Suppress("MagicNumber")
@JvmStatic
private fun getFolderConflictData(
offlineOperation: OfflineOperationEntity,
rightFile: OCFile,
context: Context
): ConflictDialogData {
val folderName = null
val leftTitle = context.getString(R.string.prefs_synced_folders_local_path_title)
val leftTimestamp =
DisplayUtils.getRelativeTimestamp(context, offlineOperation.createdAt?.times(1000L) ?: 0)
val leftFileSize = DisplayUtils.bytesToHumanReadable(0)
val leftCheckBoxData = ConflictFileData(leftTitle, leftTimestamp.toString(), leftFileSize)
val rightTitle = context.getString(R.string.prefs_synced_folders_remote_path_title)
val rightTimestamp = DisplayUtils.getRelativeTimestamp(context, rightFile.modificationTimestamp)
val rightFileSize = DisplayUtils.bytesToHumanReadable(rightFile.fileLength)
val rightCheckBoxData = ConflictFileData(rightTitle, rightTimestamp.toString(), rightFileSize)
val title = context.getString(R.string.conflict_folder_headline)
val description = context.getString(R.string.conflict_message_description_for_folder)
return ConflictDialogData(folderName, title, description, Pair(leftCheckBoxData, rightCheckBoxData))
}
@JvmStatic
private fun getFileConflictData(file: File, rightFile: OCFile, context: Context): ConflictDialogData {
val parentFile = File(rightFile.remotePath).parentFile
val folderName = if (parentFile != null) {
String.format(context.getString(R.string.in_folder), parentFile.absolutePath)
} else {
null
}
val leftTitle = context.getString(R.string.conflict_local_file)
val leftTimestamp = DisplayUtils.getRelativeTimestamp(context, file.lastModified())
val leftFileSize = DisplayUtils.bytesToHumanReadable(file.length())
val leftCheckBoxData = ConflictFileData(leftTitle, leftTimestamp.toString(), leftFileSize)
val rightTitle = context.getString(R.string.conflict_server_file)
val rightTimestamp = DisplayUtils.getRelativeTimestamp(context, rightFile.modificationTimestamp)
val rightFileSize = DisplayUtils.bytesToHumanReadable(rightFile.fileLength)
val rightCheckBoxData = ConflictFileData(rightTitle, rightTimestamp.toString(), rightFileSize)
val title = context.getString(R.string.choose_which_file)
val description = context.getString(R.string.conflict_message_description)
return ConflictDialogData(folderName, title, description, Pair(leftCheckBoxData, rightCheckBoxData))
}
}
}

View file

@ -19,22 +19,28 @@ import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.collect.Sets
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.utils.extensions.getParcelableArgument
import com.nextcloud.utils.fileNameValidator.FileNameValidator
import com.owncloud.android.R
import com.owncloud.android.databinding.EditBoxDialogBinding
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.status.OCCapability
import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FileDisplayActivity
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.KeyboardUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -55,9 +61,12 @@ class CreateFolderDialogFragment : DialogFragment(), DialogInterface.OnClickList
lateinit var keyboardUtils: KeyboardUtils
@Inject
lateinit var currentAccount: CurrentAccountProvider
lateinit var connectivityService: ConnectivityService
private var mParentFolder: OCFile? = null
@Inject
lateinit var accountProvider: CurrentAccountProvider
private var parentFolder: OCFile? = null
private var positiveButton: MaterialButton? = null
private lateinit var binding: EditBoxDialogBinding
@ -92,7 +101,7 @@ class CreateFolderDialogFragment : DialogFragment(), DialogInterface.OnClickList
@Suppress("EmptyFunctionBlock")
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
mParentFolder = arguments?.getParcelableArgument(ARG_PARENT_FOLDER, OCFile::class.java)
parentFolder = arguments?.getParcelableArgument(ARG_PARENT_FOLDER, OCFile::class.java)
val inflater = requireActivity().layoutInflater
binding = EditBoxDialogBinding.inflate(inflater, null, false)
@ -121,7 +130,7 @@ class CreateFolderDialogFragment : DialogFragment(), DialogInterface.OnClickList
return builder.create()
}
private fun getOCCapability(): OCCapability = fileDataStorageManager.getCapability(currentAccount.user.accountName)
private fun getOCCapability(): OCCapability = fileDataStorageManager.getCapability(accountProvider.user.accountName)
private fun checkFileNameAfterEachType(fileNames: MutableSet<String>) {
val newFileName = binding.userInput.text?.toString()?.trim() ?: ""
@ -173,14 +182,30 @@ class CreateFolderDialogFragment : DialogFragment(), DialogInterface.OnClickList
return
}
val path = mParentFolder?.decryptedRemotePath + newFolderName + OCFile.PATH_SEPARATOR
if (requireActivity() is ComponentsGetter) {
(requireActivity() as ComponentsGetter).fileOperationsHelper.createFolder(path)
val path = parentFolder?.decryptedRemotePath + newFolderName + OCFile.PATH_SEPARATOR
lifecycleScope.launch(Dispatchers.IO) {
if (connectivityService.isNetworkAndServerAvailable()) {
(requireActivity() as ComponentsGetter).fileOperationsHelper.createFolder(path)
} else {
Log_OC.d(TAG, "Network not available, creating offline operation")
fileDataStorageManager.addCreateFolderOfflineOperation(
path,
newFolderName,
parentFolder?.offlineOperationParentPath,
parentFolder?.fileId
)
launch(Dispatchers.Main) {
val fileDisplayActivity = requireActivity() as? FileDisplayActivity
fileDisplayActivity?.syncAndUpdateFolder(true)
}
}
}
}
}
companion object {
private const val TAG = "CreateFolderDialogFragment"
private const val ARG_PARENT_FOLDER = "PARENT_FOLDER"
const val CREATE_FOLDER_FRAGMENT = "CREATE_FOLDER_FRAGMENT"

View file

@ -18,9 +18,12 @@ import androidx.appcompat.app.AlertDialog
import com.google.android.material.button.MaterialButton
import com.nextcloud.client.di.Injectable
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FileDisplayActivity
import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener
import javax.inject.Inject
/**
* Dialog requiring confirmation before removing a collection of given OCFiles.
@ -30,14 +33,19 @@ class RemoveFilesDialogFragment : ConfirmationDialogFragment(), ConfirmationDial
private var mTargetFiles: Collection<OCFile>? = null
private var actionMode: ActionMode? = null
@Inject
lateinit var fileDataStorageManager: FileDataStorageManager
private var positiveButton: MaterialButton? = null
override fun onStart() {
super.onStart()
val alertDialog = dialog as AlertDialog? ?: return
val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton
positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton
positiveButton?.let {
viewThemeUtils?.material?.colorMaterialButtonPrimaryTonal(positiveButton)
viewThemeUtils?.material?.colorMaterialButtonPrimaryTonal(it)
}
val negativeButton = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton
@ -76,8 +84,22 @@ class RemoveFilesDialogFragment : ConfirmationDialogFragment(), ConfirmationDial
}
private fun removeFiles(onlyLocalCopy: Boolean) {
val cg = activity as ComponentsGetter?
cg?.fileOperationsHelper?.removeFiles(mTargetFiles, onlyLocalCopy, false)
val (offlineFiles, files) = mTargetFiles?.partition { it.isOfflineOperation } ?: Pair(emptyList(), emptyList())
offlineFiles.forEach {
fileDataStorageManager.deleteOfflineOperation(it)
}
if (files.isNotEmpty()) {
val cg = activity as ComponentsGetter?
cg?.fileOperationsHelper?.removeFiles(files, onlyLocalCopy, false)
}
if (offlineFiles.isNotEmpty()) {
val activity = requireActivity() as? FileDisplayActivity
activity?.refreshCurrentDirectory()
}
finishActionMode()
}
@ -151,7 +173,8 @@ class RemoveFilesDialogFragment : ConfirmationDialogFragment(), ConfirmationDial
putInt(ARG_POSITIVE_BTN_RES, R.string.file_delete)
if (containsFolder || containsDown) {
val isAnyFileOffline = files.any { it.isOfflineOperation }
if ((containsFolder || containsDown) && !isAnyFileOffline) {
putInt(ARG_NEGATIVE_BTN_RES, R.string.confirmation_remove_local)
}

View file

@ -24,14 +24,15 @@ import com.google.common.collect.Sets
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.client.di.Injectable
import com.nextcloud.utils.extensions.getParcelableArgument
import com.nextcloud.utils.fileNameValidator.FileNameValidator.isFileHidden
import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFileName
import com.nextcloud.utils.fileNameValidator.FileNameValidator.isFileHidden
import com.owncloud.android.R
import com.owncloud.android.databinding.EditBoxDialogBinding
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.status.OCCapability
import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FileDisplayActivity
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.KeyboardUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
@ -144,9 +145,14 @@ class RenameFileDialogFragment : DialogFragment(), DialogInterface.OnClickListen
return
}
if (requireActivity() is ComponentsGetter) {
val componentsGetter = requireActivity() as ComponentsGetter
componentsGetter.getFileOperationsHelper().renameFile(mTargetFile, newFileName)
if (mTargetFile?.isOfflineOperation == true) {
fileDataStorageManager.renameCreateFolderOfflineOperation(mTargetFile, newFileName)
if (requireActivity() is FileDisplayActivity) {
val activity = requireActivity() as FileDisplayActivity
activity.refreshCurrentDirectory()
}
} else {
(requireActivity() as ComponentsGetter).fileOperationsHelper.renameFile(mTargetFile, newFileName)
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.owncloud.android.ui.dialog.parcel
import android.os.Parcel
import android.os.Parcelable
import com.nextcloud.utils.extensions.readParcelableCompat
data class ConflictDialogData(
val folderName: String?,
val title: String?,
val description: String,
val checkboxData: Pair<ConflictFileData, ConflictFileData>
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString() ?: "",
parcel.readString() ?: "",
parcel.readString() ?: "",
checkboxData = Pair(
parcel.readParcelableCompat(ConflictFileData::class.java.classLoader) ?: ConflictFileData("", "", ""),
parcel.readParcelableCompat(ConflictFileData::class.java.classLoader) ?: ConflictFileData("", "", "")
)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(folderName)
parcel.writeString(title)
parcel.writeString(description)
parcel.writeParcelable(checkboxData.first, flags)
parcel.writeParcelable(checkboxData.second, flags)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<ConflictDialogData> {
override fun createFromParcel(parcel: Parcel): ConflictDialogData = ConflictDialogData(parcel)
override fun newArray(size: Int): Array<ConflictDialogData?> = arrayOfNulls(size)
}
}
data class ConflictFileData(
val title: String,
val timestamp: String,
val fileSize: String
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString() ?: "",
parcel.readString() ?: "",
parcel.readString() ?: ""
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(timestamp)
parcel.writeString(fileSize)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<ConflictFileData> {
override fun createFromParcel(parcel: Parcel): ConflictFileData = ConflictFileData(parcel)
override fun newArray(size: Int): Array<ConflictFileData?> = arrayOfNulls(size)
}
}

View file

@ -452,7 +452,10 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
backgroundJobManager);
} else if (itemId == R.id.action_set_as_wallpaper) {
containerActivity.getFileOperationsHelper().setPictureAs(getFile(), getView());
} else if (itemId == R.id.action_encrypted) {// TODO implement or remove
} else if (itemId == R.id.action_retry) {
backgroundJobManager.startOfflineOperations();
} else if (itemId == R.id.action_encrypted) {
// TODO implement or remove
} else if (itemId == R.id.action_unset_encrypted) {// TODO implement or remove
}
}

View file

@ -157,6 +157,7 @@ public class OCFileListBottomSheetDialog extends BottomSheetDialog implements In
}
setupClickListener();
filterActionsForOfflineOperations();
}
private void setupClickListener() {
@ -210,6 +211,22 @@ public class OCFileListBottomSheetDialog extends BottomSheetDialog implements In
});
}
private void filterActionsForOfflineOperations() {
if (!file.isOfflineOperation() || file.isRootDirectory()) {
return;
}
binding.menuCreateRichWorkspace.setVisibility(View.GONE);
binding.menuUploadFromApp.setVisibility(View.GONE);
binding.menuDirectCameraUpload.setVisibility(View.GONE);
binding.menuScanDocUpload.setVisibility(View.GONE);
binding.menuUploadFiles.setVisibility(View.GONE);
binding.menuNewDocument.setVisibility(View.GONE);
binding.menuNewSpreadsheet.setVisibility(View.GONE);
binding.menuNewPresentation.setVisibility(View.GONE);
binding.creatorsContainer.setVisibility(View.GONE);
}
@Override
protected void onStop() {
super.onStop();

View file

@ -123,6 +123,7 @@ import org.greenrobot.eventbus.ThreadMode;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@ -628,22 +629,28 @@ public class OCFileListFragment extends ExtendedListFragment implements
public void openActionsMenu(final int filesCount, final Set<OCFile> checkedFiles, final boolean isOverflow) {
throttler.run("overflowClick", () -> {
final FragmentManager childFragmentManager = getChildFragmentManager();
List<Integer> toHide = new ArrayList<>();
for (OCFile file : checkedFiles) {
if (file.isOfflineOperation()) {
toHide = new ArrayList<>(
Arrays.asList(R.id.action_favorite, R.id.action_move_or_copy, R.id.action_sync_file, R.id.action_encrypted, R.id.action_unset_encrypted)
);
break;
}
}
if (isAPKorAAB(checkedFiles)) {
toHide.add(R.id.action_send_share_file);
toHide.add(R.id.action_export_file);
toHide.add(R.id.action_sync_file);
toHide.add(R.id.action_download_file);
}
final FragmentManager childFragmentManager = getChildFragmentManager();
FileActionsBottomSheet.newInstance(filesCount, checkedFiles, isOverflow, toHide)
.setResultListener(childFragmentManager, this, (id) -> {
onFileActionChosen(id, checkedFiles);
})
.setResultListener(childFragmentManager, this, (id) -> onFileActionChosen(id, checkedFiles))
.show(childFragmentManager, "actions");
;
});
}
@ -1108,10 +1115,6 @@ public class OCFileListFragment extends ExtendedListFragment implements
@Override
@OptIn(markerClass = UnstableApi.class)
public void onItemClicked(OCFile file) {
if (mContainerActivity != null && mContainerActivity instanceof FileActivity fileActivity) {
fileActivity.checkInternetConnection();
}
if (getCommonAdapter() != null && getCommonAdapter().isMultiSelect()) {
toggleItemToCheckedList(file);
} else {
@ -1240,6 +1243,9 @@ public class OCFileListFragment extends ExtendedListFragment implements
} else if (itemId == R.id.action_pin_to_homescreen) {
shortcutUtil.addShortcutToHomescreen(singleFile, viewThemeUtils, accountManager.getUser(), syncedFolderProvider);
return true;
} else if (itemId == R.id.action_retry) {
backgroundJobManager.startOfflineOperations();
return true;
}
}
@ -1478,7 +1484,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
}
// FAB
setFabEnabled(mFile != null && mFile.canWrite());
setFabEnabled(mFile != null && (mFile.canWrite() || mFile.isOfflineOperation()));
invalidateActionMode();
}
@ -1980,6 +1986,9 @@ public class OCFileListFragment extends ExtendedListFragment implements
*/
public void selectAllFiles(boolean select) {
OCFileListAdapter ocFileListAdapter = (OCFileListAdapter) getRecyclerView().getAdapter();
if (ocFileListAdapter == null) {
return;
}
if (select) {
ocFileListAdapter.addAllFilesToCheckedFiles();

View file

@ -33,6 +33,7 @@ public final class NotificationUtils {
public static final String NOTIFICATION_CHANNEL_FILE_SYNC = "NOTIFICATION_CHANNEL_FILE_SYNC";
public static final String NOTIFICATION_CHANNEL_FILE_OBSERVER = "NOTIFICATION_CHANNEL_FILE_OBSERVER";
public static final String NOTIFICATION_CHANNEL_PUSH = "NOTIFICATION_CHANNEL_PUSH";
public static final String NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS = "NOTIFICATION_CHANNEL_BACKGROUND_OPERATIONS";
private NotificationUtils() {
// utility class -> private constructor

View file

@ -0,0 +1,24 @@
<!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
~ SPDX-FileCopyrightText: 2024 Nextcloud GmbH
~ SPDX-FileCopyrightText: 2018-2024 Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,5V2L8,6l4,4V7c3.31,0 6,2.69 6,6c0,2.97 -2.17,5.43 -5,5.91v2.02c3.95,-0.49 7,-3.85 7,-7.93C20,8.58 16.42,5 12,5z" />
<path
android:fillColor="@android:color/white"
android:pathData="M6,13c0,-1.65 0.67,-3.15 1.76,-4.24L6.34,7.34C4.9,8.79 4,10.79 4,13c0,4.08 3.05,7.44 7,7.93v-2.02C8.17,18.43 6,15.97 6,13z" />
</vector>

View file

@ -17,44 +17,49 @@
android:paddingBottom="@dimen/standard_padding">
<TextView
android:id="@+id/in"
android:id="@+id/folder_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/in_folder"
android:paddingBottom="@dimen/standard_padding" />
<TextView
android:id="@+id/title"
android:layout_marginTop="@dimen/standard_margin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/choose_which_file"
android:textStyle="bold" />
<TextView
android:id="@+id/description"
android:layout_marginTop="@dimen/standard_margin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_message_description" />
<LinearLayout
android:layout_marginTop="@dimen/standard_margin"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:baselineAligned="false">
<LinearLayout
android:id="@+id/newFileContainer"
android:id="@+id/leftFileContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/new_checkbox"
android:id="@+id/left_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_local_file" />
<ImageView
android:id="@+id/new_thumbnail"
android:id="@+id/left_thumbnail"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_margin="@dimen/standard_half_margin"
@ -62,33 +67,33 @@
android:contentDescription="@string/thumbnail_for_new_file_desc" />
<TextView
android:id="@+id/new_timestamp"
android:id="@+id/left_timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="12. Dec 2020 - 23:10:20" />
<TextView
android:id="@+id/new_size"
android:id="@+id/left_file_size"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="5 Mb" />
</LinearLayout>
<LinearLayout
android:id="@+id/existingFileContainer"
android:id="@+id/rightFileContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/existing_checkbox"
android:id="@+id/right_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_server_file" />
<ImageView
android:id="@+id/existing_thumbnail"
android:id="@+id/right_thumbnail"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_margin="@dimen/standard_half_margin"
@ -96,13 +101,13 @@
android:contentDescription="@string/thumbnail_for_existing_file_description" />
<TextView
android:id="@+id/existing_timestamp"
android:id="@+id/right_timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="10. Dec 2020 - 10:10:10" />
<TextView
android:id="@+id/existing_size"
android:id="@+id/right_file_size"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="3 Mb" />

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">هل توَدُّ حقاً حذف العناصر المختارة وما يحتوّه؟</string>
<string name="confirmation_remove_local">محلياً فقط</string>
<string name="conflict_dialog_error">تعذّر إنشاء نافذة حوار حل التعارضات</string>
<string name="conflict_file_headline">ملف متضارب %1$s </string>
<string name="conflict_local_file">ملف محلي</string>
<string name="conflict_message_description">إذا قمت باختيار كلا الاصدارين, الملف المحلي سيحتوي على رقم ملحق باسم الملف.</string>
<string name="conflict_server_file">ملف على الخادم</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Do you really want to delete the selected items and their contents?</string>
<string name="confirmation_remove_local">Local only</string>
<string name="conflict_dialog_error">Conflict resolver dialog cannot be created</string>
<string name="conflict_file_headline">Conflicting file %1$s</string>
<string name="conflict_local_file">Local file</string>
<string name="conflict_message_description">If you select both versions, the local file will have a number appended to its name.</string>
<string name="conflict_server_file">Server file</string>

View file

@ -153,7 +153,6 @@
<string name="confirmation_remove_folder_alert">Наистина ли желаете %1$s и съдържанието ѝ да бъдат изтрито?</string>
<string name="confirmation_remove_folders_alert">Наистина ли желаете избраните елементи и съдържанието им да бъдат премахнати?</string>
<string name="confirmation_remove_local">Само локално</string>
<string name="conflict_file_headline">Несъвместим файл %1$s</string>
<string name="conflict_local_file">Локален файл</string>
<string name="conflict_message_description">Ако изберете и двете версии, ще бъде добавен номер към името на локалния файл.</string>
<string name="conflict_server_file">Файл на сървъра</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Ha sur oc\'h e fell deoc\'h dilemel an elfennoù diuzet ha kement tra zo e-barzh . </string>
<string name="confirmation_remove_local">Lec\'hel hepken</string>
<string name="conflict_dialog_error">N\'eus ket bet gallet krouiñ un egorenn diskoulmañ tabutoù</string>
<string name="conflict_file_headline">Restr kudennek %1$s</string>
<string name="conflict_local_file">Restr lec\'hel</string>
<string name="conflict_message_description">Ma tibabit an daou stumm e vo staget un niverenn ouzh anv ar restr lec\'hel</string>
<string name="conflict_server_file">Restr ar servijer</string>

View file

@ -152,7 +152,6 @@
<string name="confirmation_remove_folder_alert">Esteu segur que voleu suprimir %1$s i els seus continguts?</string>
<string name="confirmation_remove_folders_alert">Esteu segur que voleu suprimir els elements seleccionats i el seu contingut?</string>
<string name="confirmation_remove_local">Només local</string>
<string name="conflict_file_headline">Fitxer en conflicte %1$s</string>
<string name="conflict_local_file">Fitxer local</string>
<string name="conflict_message_description">Si seleccioneu ambdues versions, s\'afegirà un numero al nom del fitxer local.</string>
<string name="conflict_server_file">Fitxer del servidor</string>

View file

@ -170,7 +170,6 @@
<string name="confirmation_remove_folder_alert">Opravdu chcete %1$s a jeho obsah odstranit?</string>
<string name="confirmation_remove_folders_alert">Opravdu chcete vybrané položky a jejich obsah odstranit?</string>
<string name="confirmation_remove_local">Pouze místní</string>
<string name="conflict_file_headline">Kolidující soubor %1$s</string>
<string name="conflict_local_file">Místní soubor</string>
<string name="conflict_message_description">Pokud zvolíte obě verze, k názvu místního souboru bude připojeno číslo.</string>
<string name="conflict_server_file">Soubor na serveru</string>

View file

@ -164,7 +164,6 @@
<string name="confirmation_remove_folder_alert">Er du sikker på at du vil slette %1$s med indhold?</string>
<string name="confirmation_remove_folders_alert">Er du sikker på at du vil slette de valgte artikler med indhold?</string>
<string name="confirmation_remove_local">Kun lokal</string>
<string name="conflict_file_headline">Fil i konflikt %1$s</string>
<string name="conflict_local_file">Lokal fil</string>
<string name="conflict_message_description">Hvis du vælger begge versioner, vil den lokale fil få tilføjet et nummer til sit navn.</string>
<string name="conflict_server_file">Server fil</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Möchten Sie die ausgewählten Elemente und deren inhalt wirklich löschen?</string>
<string name="confirmation_remove_local">Nur lokal</string>
<string name="conflict_dialog_error">Konfliktlösungsdialog konnte nicht erstellt werden</string>
<string name="conflict_file_headline">Konflikt-Datei %1$s</string>
<string name="conflict_local_file">Lokale Datei</string>
<string name="conflict_message_description">Falls beide Versionen gewählt werden, wird bei der lokalen Datei eine Zahl am Ende des Dateinamens hinzugefügt.</string>
<string name="conflict_server_file">Server-Datei</string>

View file

@ -151,7 +151,6 @@
<string name="confirmation_remove_folder_alert">Θέλετε σίγουρα να διαγράψετε το %1$s και τα περιεχόμενά του;</string>
<string name="confirmation_remove_folders_alert">Θέλετε να διαγράψετε τα επιλεγμένα αντικείμενα και τα περιεχόμενά τους;</string>
<string name="confirmation_remove_local">Μόνο τοπικά</string>
<string name="conflict_file_headline">Αρχείο σε αντίφαση %1$s</string>
<string name="conflict_local_file">Τοπικό αρχείο</string>
<string name="conflict_message_description">Εάν επιλέξετε και τις δύο εκδόσεις, στο όνομα του τοπικού αρχείου θα προστεθεί ένας αριθμός.</string>
<string name="conflict_server_file">Αρχείο διακομιστή</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">¿Realmente desea eliminar los elementos seleccionados y sus contenidos?</string>
<string name="confirmation_remove_local">Sólo local</string>
<string name="conflict_dialog_error">El diálogo de resolución de conflictos no puede ser creado</string>
<string name="conflict_file_headline">Archivo en conflicto %1$s</string>
<string name="conflict_local_file">Archivo local</string>
<string name="conflict_message_description">Si selecciona ambas versiones, se le agregará un número al nombre del archivo copiado.</string>
<string name="conflict_server_file">Archivo del servidor</string>

View file

@ -153,7 +153,6 @@
<string name="confirmation_remove_folder_alert">¿Realmente quieres eliminar %1$s y sus contenidos? </string>
<string name="confirmation_remove_folders_alert">¿Reamente deseas eliminar los elementos seleccionados y sus contenidos?</string>
<string name="confirmation_remove_local">Sólo local</string>
<string name="conflict_file_headline">Archivo en conflicto %1$s</string>
<string name="conflict_local_file">Archivo local</string>
<string name="conflict_message_description">Si selecciona ambas versiones, el archivo local tendrá un número agregado a su nombre.</string>
<string name="conflict_server_file">Archivo del servidor</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">¿Reamente deseas eliminar los elementos seleccionados y sus contenidos?</string>
<string name="confirmation_remove_local">Sólo local</string>
<string name="conflict_dialog_error">El diálogo de resolución de conflictos no puede ser creado</string>
<string name="conflict_file_headline">Archivo conflictivo %1$s</string>
<string name="conflict_local_file">Archivo local</string>
<string name="conflict_message_description">Si seleccionas ambas versiones, el archivo local tendrá un número al final del nombre.</string>
<string name="conflict_server_file">Archivo del servidor</string>

View file

@ -178,7 +178,6 @@
<string name="confirmation_remove_folders_alert">¿Estás seguro de que quieres eliminar los elementos seleccionados y sus contenidos?</string>
<string name="confirmation_remove_local">Solo local</string>
<string name="conflict_dialog_error">El diálogo de resolución de conflictos no puede ser creado</string>
<string name="conflict_file_headline">Conflicto en archivo %1$s</string>
<string name="conflict_local_file">Archivo local</string>
<string name="conflict_message_description">Si seleccionas ambas versiones, el archivo local tendrá un número añadido a su nombre.</string>
<string name="conflict_server_file">Archivo del servidor</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Ziur zaude hautatutako elementuak eta beren edukiak ezabatu nahi dituzula?</string>
<string name="confirmation_remove_local">Lokala bakarrik</string>
<string name="conflict_dialog_error">Gatazkak konpontzeko elkarrizketa-koadroa ezin da sortu</string>
<string name="conflict_file_headline">%1$sfitxategi gatazkatsua</string>
<string name="conflict_local_file">Fitxategi lokala</string>
<string name="conflict_message_description">Bi bertsioak hautatzen badituzu, fitxategi lokalaren izenari zenbaki bat gehituko zaio.</string>
<string name="conflict_server_file">Zerbitzariko fitxategia</string>

View file

@ -158,7 +158,6 @@
<string name="confirmation_remove_folder_alert">آیا واقعا می خواهید %1$s و محتویات آن را حذف کنید؟</string>
<string name="confirmation_remove_folders_alert">آیا واقعاً می‌خواهید موارد انتخاب شده و محتوای آنها حذف شود؟</string>
<string name="confirmation_remove_local">فقط محلی</string>
<string name="conflict_file_headline">فایل متناقض %1$s</string>
<string name="conflict_local_file">پروندهٔ محلّی</string>
<string name="conflict_message_description">اگر هردو نسخه را انتخاب کنید، یک شماره به نام فایل محلی اضافه خواهد شد.</string>
<string name="conflict_server_file">پروندهٔ کارساز</string>

View file

@ -158,7 +158,6 @@
<string name="confirmation_remove_folder_alert">Haluatko varmasti poistaa kohteen %1$s ja sen sisällön?</string>
<string name="confirmation_remove_folders_alert">Haluatko varmasti poistaa valitut kohteet ja niiden sisällön?</string>
<string name="confirmation_remove_local">Vain paikallisen</string>
<string name="conflict_file_headline">Ristiriitainen kohde %1$s</string>
<string name="conflict_local_file">Paikallinen tiedosto</string>
<string name="conflict_message_description">Jos valitset molemmat versiot, paikallisen tiedoston nimeen lisätään numero.</string>
<string name="conflict_server_file">Palvelintiedosto</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Souhaitez-vous vraiment supprimer les éléments sélectionnés ainsi que leurs contenus ?</string>
<string name="confirmation_remove_local">Local seulement</string>
<string name="conflict_dialog_error">Erreur lors de la création de la boîte de dialogue de conflit !</string>
<string name="conflict_file_headline">Fichier %1$s en conflit</string>
<string name="conflict_local_file">fichier local</string>
<string name="conflict_message_description">Si vous sélectionnez les deux versions, le fichier local aura un numéro ajouté à son nom.</string>
<string name="conflict_server_file">Fichier serveur</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">An bhfuil tú cinnte gur mhaith leat na míreanna roghnaithe agus a bhfuil iontu a scriosadh?</string>
<string name="confirmation_remove_local">Áitiúil amháin</string>
<string name="conflict_dialog_error">Ní féidir dialóg réititheora coinbhleachta a chruthú</string>
<string name="conflict_file_headline">Comhad contrártha %1$s</string>
<string name="conflict_local_file">Comhad áitiúil</string>
<string name="conflict_message_description">Má roghnaíonn tú an dá leagan, beidh uimhir ag gabháil leis an gcomhad áitiúil lena ainm.</string>
<string name="conflict_server_file">Comhad freastalaí</string>

View file

@ -137,7 +137,6 @@
<string name="confirmation_remove_folder_alert">A bheil thu cinnteach gu bheil thu airson %1$s s a shusbaint a sguabadh às?</string>
<string name="confirmation_remove_folders_alert">A bheil thu cinnteach gu bheil thu airson na nithean a thagh thu s an susbaint a sguabadh às?</string>
<string name="confirmation_remove_local">Ionadail a-mhàin</string>
<string name="conflict_file_headline">Faidhle %1$s ann an còmhstri</string>
<string name="conflict_message_description">Ma thaghas tu an dà thionndadh, thèid àireamh a chur ri ainm an fhaidhle ionadail.</string>
<string name="contactlist_item_icon">Ìomhaigheag a chleachdaiche air liosta an luchd-aithne</string>
<string name="contactlist_no_permission">Cha deach cead a thoirt s cha deach càil ion-phortadh.</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Confirma que quere eliminar os elementos seleccionados e o seu contido?</string>
<string name="confirmation_remove_local">Só local</string>
<string name="conflict_dialog_error">Non é posíbel crear o diálogo de resolución de conflitos</string>
<string name="conflict_file_headline">Ficheiro en conflito %1$s</string>
<string name="conflict_local_file">Ficheiro local</string>
<string name="conflict_message_description">Se selecciona ambas versións, o ficheiro local terá un número engadido ao nome.</string>
<string name="conflict_server_file">Ficheiro do servidor</string>
@ -690,7 +689,7 @@
<string name="preview_image_error_unknown_format">Non é posíbel amosar a imaxe</string>
<string name="preview_image_file_is_not_downloaded">O ficheiro non está descargado</string>
<string name="preview_image_file_is_not_exist">O ficheiro non existe</string>
<string name="preview_media_unhandled_http_code_message">O ficheiro está bloqueado actualmente por outro usuario ou proceso e, polo tanto, non é posíbel eliminalo. Ténteo de novo máis tarde.</string>
<string name="preview_media_unhandled_http_code_message">O ficheiro está bloqueado actualmente por outro usuario ou proceso e, por tanto, non é posíbel eliminalo. Ténteo de novo máis tarde.</string>
<string name="preview_sorry">Desculpe.</string>
<string name="privacy">Privacidade</string>
<string name="public_share_name">Nome novo</string>

View file

@ -145,7 +145,6 @@
<string name="confirmation_remove_folder_alert">Želite li zaista izbrisati %1$s i pripadajući sadržaj?</string>
<string name="confirmation_remove_folders_alert">Želite li zaista izbrisati odabrane stavke i pripadajući sadržaj?</string>
<string name="confirmation_remove_local">Samo lokalno</string>
<string name="conflict_file_headline">Nepodudarna datoteka %1$s</string>
<string name="conflict_local_file">Lokalna datoteka</string>
<string name="conflict_message_description">Ako odaberete obje inačice, lokalna će datoteka uz naziv imati i broj.</string>
<string name="conflict_server_file">Datoteka na poslužitelju</string>

View file

@ -155,7 +155,6 @@
<string name="confirmation_remove_folder_alert">Biztos, hogy törli ezt: %1$s és a tartalmát?</string>
<string name="confirmation_remove_folders_alert">Biztos, hogy törli a kiválasztott elemeket és tartalmukat?</string>
<string name="confirmation_remove_local">Csak a helyi példány</string>
<string name="conflict_file_headline">Ütköző fájl: %1$s</string>
<string name="conflict_local_file">Helyi fájl</string>
<string name="conflict_message_description">Amennyiben mindkét verziót kiválasztja, a helyi fájl nevéhez egy szám lesz hozzáfűzve.</string>
<string name="conflict_server_file">Kiszolgálón lévő fájl</string>

View file

@ -173,7 +173,6 @@ Otomatis unggah hanya bekerja dengan baik apabila Anda mengeluarkan aplikasi ini
<string name="confirmation_remove_folders_alert">Apa anda yakin ingin menghapus item yang terpilih beserta isinya?</string>
<string name="confirmation_remove_local">Lokal saja</string>
<string name="conflict_dialog_error">Dialog penyelesaian konflik tidak dapat dibuat</string>
<string name="conflict_file_headline">File konflik %1$s</string>
<string name="conflict_local_file">File lokal</string>
<string name="conflict_message_description">Jika Anda memilih kedua versi, nama dari berkas lokal akan ditambahi angka.</string>
<string name="conflict_server_file">File server</string>

View file

@ -169,7 +169,6 @@
<string name="confirmation_remove_folder_alert">Vuoi davvero rimuovere %1$s e il relativo contenuto?</string>
<string name="confirmation_remove_folders_alert">Vuoi davvero eliminare gli elementi selezionati e il loro contenuto?</string>
<string name="confirmation_remove_local">Solo localmente</string>
<string name="conflict_file_headline">File %1$s in conflitto</string>
<string name="conflict_local_file">File locale</string>
<string name="conflict_message_description">Se selezioni entrambe le versioni, il file locale ha un numero aggiunto al suo nome.</string>
<string name="conflict_server_file">File su server</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">本当に選択したアイテムとその内容を削除しますか?</string>
<string name="confirmation_remove_local">ローカルのみ</string>
<string name="conflict_dialog_error">競合解決ダイアログを作成できません</string>
<string name="conflict_file_headline">%1$sはすでに存在します</string>
<string name="conflict_local_file">ローカルファイル</string>
<string name="conflict_message_description">両方のバージョンを選択した場合、ローカルファイルはファイル名に数字が追加されます。</string>
<string name="conflict_server_file">サーバーファイル</string>

View file

@ -151,7 +151,6 @@
<string name="confirmation_remove_folder_alert">Do you really want to delete %1$s and the contents thereof?</string>
<string name="confirmation_remove_folders_alert">Do you really want to delete the selected items and their contents?</string>
<string name="confirmation_remove_local">Local only</string>
<string name="conflict_file_headline">Conflicting file %1$s</string>
<string name="conflict_local_file">Local file</string>
<string name="conflict_message_description">If you select both versions, the local file will have a number appended to its name.</string>
<string name="conflict_server_file">Server file</string>

View file

@ -178,7 +178,6 @@
<string name="confirmation_remove_folders_alert">선택한 항목과 포함된 내용을 삭제하시겠습니까?</string>
<string name="confirmation_remove_local">로컬만</string>
<string name="conflict_dialog_error">충돌 해결 프로그램 대화 상자를 만들 수 없습니다.</string>
<string name="conflict_file_headline">충돌하는 파일 %1$s</string>
<string name="conflict_local_file">로컬 파일</string>
<string name="conflict_message_description">두 버전을 모두 선택하면 기존 파일 이름에 번호가 추가됩니다.</string>
<string name="conflict_server_file">서버 파일</string>

View file

@ -144,7 +144,6 @@
<string name="confirmation_remove_folder_alert">ທ່ານຕ້ອງການ ລຶບ%1$s ແລະ ເນື້ອຫາບໍ?</string>
<string name="confirmation_remove_folders_alert">ທ່ານຕ້ອງການລຶບລາຍການທີ່ເລືອກ ແລະ ເນື້ອຫາແທ້ບໍ?</string>
<string name="confirmation_remove_local">ຊ່ອງເກັບຢ່າງດຽວ</string>
<string name="conflict_file_headline">ຟາຍຜິດພາດ%1$s</string>
<string name="conflict_message_description">ຖ້າທ່ານເລືອກເອົາທັງສອງເວີຊັ້ນ, ບ່ອນເກັບຟາຍຈະມີຈໍານວນສະສົມ</string>
<string name="contactlist_item_icon">ລາຍການໄອຄອນຜູ້ຕິດຕໍ່</string>
<string name="contactlist_no_permission">ບໍ່ໄດ້ຮັບອະນຸຍາດ, ບໍ່ມີຫຍັງນໍາເຂົ້າ.</string>

View file

@ -147,7 +147,6 @@
<string name="confirmation_remove_folder_alert">Ar tikrai norite ištrinti %1$s ir jo turinį?</string>
<string name="confirmation_remove_folders_alert">Ar tikrai norite ištrinti pažymėtus elementus ir jų turinį?</string>
<string name="confirmation_remove_local">Tik vietiniai</string>
<string name="conflict_file_headline">Nesuderinamas failas%1$s</string>
<string name="conflict_local_file">Vietinis failas</string>
<string name="conflict_message_description">Jei pasirinksite abi versijas, vietinis failas prie pavadinimo turės numerį.</string>
<string name="conflict_server_file">Failas iš serverio</string>

View file

@ -132,7 +132,6 @@
<string name="confirmation_remove_folder_alert">Vai tiešām vēlaties izdzēst %1$s un tā saturu?</string>
<string name="confirmation_remove_folders_alert">Vai tiešām vēlies dzēst izvēlētos objektus un to saturu?</string>
<string name="confirmation_remove_local">Tikai lokālos</string>
<string name="conflict_file_headline">Konfliktējošs fails %1$s</string>
<string name="contactlist_item_icon">Lietotāja ikona kontaktpersonu sarakstam</string>
<string name="contactlist_no_permission">Nav dota atļauja, importēšana neizdevās</string>
<string name="contacts">Kontakti</string>

View file

@ -144,7 +144,6 @@
<string name="confirmation_remove_folder_alert">али си сигурен дека сакаш да ја избришеш %1$s и содржината во истата?</string>
<string name="confirmation_remove_folders_alert">Дали си сигурен дека сакаш да ја избришеш означената ставкаи содржината во неа?</string>
<string name="confirmation_remove_local">Само локално</string>
<string name="conflict_file_headline">Датотеки со конфликт %1$s</string>
<string name="conflict_local_file">Локална датотека</string>
<string name="conflict_message_description">Ако ги одберете и двете верзии, локалната датотека ќе има број додаден на нејзиното име.</string>
<string name="conflict_server_file">Серверска датотека</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Vil du virkelig fjerne de valgte elementene og dets innhold?</string>
<string name="confirmation_remove_local">Kun lokalt</string>
<string name="conflict_dialog_error">Dialogboksen Konfliktløser kan ikke opprettes</string>
<string name="conflict_file_headline">Konflikt med%1$s</string>
<string name="conflict_local_file">Lokal fil</string>
<string name="conflict_message_description">Hvis du velger begge versjonene vil den lokale filen få et tall lagt til på slutten av navnet.</string>
<string name="conflict_server_file">Server fil</string>
@ -487,6 +486,8 @@
<string name="instant_upload_existing">Last også opp eksisterende filer</string>
<string name="instant_upload_on_charging">Bare last opp under lading</string>
<string name="instant_upload_path">/Direkteopplasting</string>
<string name="internal_two_way_sync">Intern toveis synkronisering</string>
<string name="internal_two_way_sync_not_yet">Ikke enda, snart synkronisert</string>
<string name="invalid_url">Ugyldig URL</string>
<string name="invisible">Usynlig</string>
<string name="label_empty">Merkelapp kan ikke være tom</string>
@ -677,6 +678,7 @@
<string name="prefs_synced_folders_local_path_title">Lokal mappe</string>
<string name="prefs_synced_folders_remote_path_title">Mappe på server</string>
<string name="prefs_theme_title">Tema</string>
<string name="prefs_two_way_sync_summary">Administrer interne mapper for toveis synkronisering</string>
<string name="prefs_value_theme_dark">Mørk</string>
<string name="prefs_value_theme_light">Lys</string>
<string name="prefs_value_theme_system">Følg system</string>

View file

@ -178,7 +178,6 @@
<string name="confirmation_remove_folders_alert">Wil je de geselecteerde objecten en hun inhoud echt verwijderen?</string>
<string name="confirmation_remove_local">Alleen lokaal</string>
<string name="conflict_dialog_error">Conflictoplossingsvenster kan niet geladen worden</string>
<string name="conflict_file_headline">Conflicterend bestand%1$s</string>
<string name="conflict_local_file">Lokaal bestand</string>
<string name="conflict_message_description">Als je beide versies selecteert, zal het lokale bestand een nummer aan de naam toegevoegd krijgen.</string>
<string name="conflict_server_file">Serverbestand</string>

View file

@ -176,7 +176,6 @@
<string name="confirmation_remove_folders_alert">Czy na pewno chcesz usunąć wybrane pozycje i ich zawartość?</string>
<string name="confirmation_remove_local">Tylko lokalnie</string>
<string name="conflict_dialog_error">Nie można utworzyć okna dialogowego rozwiązywania konfliktów</string>
<string name="conflict_file_headline">Plik powodujący konflikt %1$s</string>
<string name="conflict_local_file">Plik lokalny</string>
<string name="conflict_message_description">Jeśli wybierzesz obie wersje, to do nazwy pliku lokalnego zostanie dodany numer.</string>
<string name="conflict_server_file">Plik z serwera</string>

View file

@ -179,7 +179,6 @@
<string name="confirmation_remove_folders_alert">Quer realmente excluir os itens selecionados e seus conteúdos?</string>
<string name="confirmation_remove_local">Somente local</string>
<string name="conflict_dialog_error">A caixa de diálogo de resolução de conflitos não pode ser criada</string>
<string name="conflict_file_headline">Arquivo conflitante %1$s</string>
<string name="conflict_local_file">Arquivo local</string>
<string name="conflict_message_description">Se você selecionar as duas versões, o arquivo local terá um número anexado ao seu nome.</string>
<string name="conflict_server_file">Arquivo do servidor</string>
@ -279,8 +278,11 @@
<string name="drawer_quota">%1$s de %2$s usados</string>
<string name="drawer_quota_unlimited">%1$s usados</string>
<string name="drawer_synced_folders">Autoenvio</string>
<string name="e2e_counter_too_old">O contador é muito antigo</string>
<string name="e2e_hash_not_found">Hash não encontrado</string>
<string name="e2e_not_yet_setup">E2E ainda não configurado</string>
<string name="e2e_offline">Não é possível sem conexão com a internet</string>
<string name="e2e_signature_does_not_match">A assinatura não corresponde</string>
<string name="ecosystem_apps_display_assistant">Assistente</string>
<string name="ecosystem_apps_display_more">Mais</string>
<string name="ecosystem_apps_display_notes">Notas </string>
@ -406,6 +408,14 @@
<string name="file_migration_updating_index">Atualizando índice…</string>
<string name="file_migration_use_data_folder">Usar</string>
<string name="file_migration_waiting_for_unfinished_sync">Aguardando sincronização completa…</string>
<string name="file_name_validator_current_path_is_invalid">O nome da pasta atual é inválido, renomeie a pasta. Redirecionando para a raiz</string>
<string name="file_name_validator_error_contains_reserved_names_or_invalid_characters">O caminho da pasta contém nomes reservados ou caracteres inválidos</string>
<string name="file_name_validator_error_ends_with_space_period">O nome termina com um espaço ou um ponto</string>
<string name="file_name_validator_error_forbidden_file_extensions">.%s é uma extensão de arquivo proibida</string>
<string name="file_name_validator_error_invalid_character">O nome contém um caractere inválido:%s</string>
<string name="file_name_validator_error_reserved_names">%s é um nome proibido</string>
<string name="file_name_validator_rename_before_move_or_copy">%s. Renomeie o arquivo antes de mover ou copiar</string>
<string name="file_name_validator_upload_content_error">Alguns conteúdos não podem ser carregados porque contêm nomes reservados ou caracteres inválidos</string>
<string name="file_not_found">Arquivo não encontrado</string>
<string name="file_not_synced">Arquivo não pôde ser sincronizado. Mostrando a última versão disponível.</string>
<string name="file_rename">Renomear</string>
@ -429,6 +439,7 @@
<string name="folder_already_exists">Pasta já existe</string>
<string name="folder_confirm_create">Criar</string>
<string name="folder_list_empty_headline">Sem pastas aqui</string>
<string name="folder_name_empty">O nome da pasta não pode ficar vazio</string>
<string name="folder_picker_choose_button_text">Escolher</string>
<string name="folder_picker_choose_caption_text">Escolher pasta destino</string>
<string name="folder_picker_copy_button_text">Copiar</string>
@ -475,6 +486,8 @@
<string name="instant_upload_existing">Enviar arquivos existentes também</string>
<string name="instant_upload_on_charging">Só enviar quando carregando</string>
<string name="instant_upload_path">/EnvioAutomático</string>
<string name="internal_two_way_sync">Sincronização interna bidirecional</string>
<string name="internal_two_way_sync_not_yet">Ainda não, em breve será sincronizado</string>
<string name="invalid_url">URL inválida</string>
<string name="invisible">Invisível</string>
<string name="label_empty">A etiqueta não pode ficar vazia</string>
@ -665,6 +678,7 @@
<string name="prefs_synced_folders_local_path_title">Pasta local</string>
<string name="prefs_synced_folders_remote_path_title">Pasta remota</string>
<string name="prefs_theme_title">Tema</string>
<string name="prefs_two_way_sync_summary">Gerencie pastas internas para sincronização bidirecional</string>
<string name="prefs_value_theme_dark">Escuro</string>
<string name="prefs_value_theme_light">Claro</string>
<string name="prefs_value_theme_system">Seguir o sistema</string>

View file

@ -149,7 +149,6 @@
<string name="confirmation_remove_folder_alert">Deseja realmente apagar %1$s e o seu conteúdo?</string>
<string name="confirmation_remove_folders_alert">Quer realmente apagar os itens seleccionados e os seus conteúdos?</string>
<string name="confirmation_remove_local">Apenas localmente</string>
<string name="conflict_file_headline">Ficheiro em conflito %1$s</string>
<string name="conflict_local_file">Ficheiro local</string>
<string name="conflict_message_description">Se selecionou ambas as versões, o ficheiro local terá um número acrescentado ao seu nome.</string>
<string name="conflict_server_file">Ficheiro do servidor</string>

View file

@ -174,7 +174,6 @@
<string name="confirmation_remove_folder_alert">Sigur vreți să eliminați %1$s și conținutul său?</string>
<string name="confirmation_remove_folders_alert">Doriți să ștergeți elementele selectate și conținutul lor?</string>
<string name="confirmation_remove_local">Doar local</string>
<string name="conflict_file_headline">Conflict cu fișierul %1$s</string>
<string name="conflict_local_file">Fișier local</string>
<string name="conflict_message_description">Dacă selectezi ambele variante, atunci fișierul local va avea un număr adăugat la numele său.</string>
<string name="conflict_server_file">Fișier pe server</string>

Some files were not shown because too many files have changed in this diff Show more