main branch updated

This commit is contained in:
Fr4nz D13trich 2025-11-20 16:16:40 +01:00
parent 3d33d3fe49
commit 9a05dc1657
353 changed files with 16802 additions and 2995 deletions

View file

@ -0,0 +1,135 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.utils.extensions.shouldSkipFile
import com.nextcloud.utils.extensions.toLocalPath
import com.owncloud.android.datamodel.FilesystemDataProvider
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import java.io.IOException
import java.nio.file.AccessDeniedException
import java.nio.file.FileVisitOption
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
@Suppress("TooGenericExceptionCaught", "MagicNumber", "ReturnCount")
class AutoUploadHelper {
companion object {
private const val TAG = "AutoUploadHelper"
private const val MAX_DEPTH = 100
}
fun insertCustomFolderIntoDB(folder: SyncedFolder, filesystemDataProvider: FilesystemDataProvider?): Int {
val path = Paths.get(folder.localPath)
if (!Files.exists(path)) {
Log_OC.w(TAG, "Folder does not exist: ${folder.localPath}")
return 0
}
if (!Files.isReadable(path)) {
Log_OC.w(TAG, "Folder is not readable: ${folder.localPath}")
return 0
}
val excludeHidden = folder.isExcludeHidden
var fileCount = 0
var skipCount = 0
var errorCount = 0
try {
Files.walkFileTree(
path,
setOf(FileVisitOption.FOLLOW_LINKS),
MAX_DEPTH,
object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes?): FileVisitResult {
if (excludeHidden && dir != path && dir.toFile().isHidden) {
Log_OC.d(TAG, "Skipping hidden directory: ${dir.fileName}")
skipCount++
return FileVisitResult.SKIP_SUBTREE
}
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes?): FileVisitResult {
try {
val javaFile = file.toFile()
val lastModified = attrs?.lastModifiedTime()?.toMillis() ?: javaFile.lastModified()
val creationTime = attrs?.creationTime()?.toMillis()
if (folder.shouldSkipFile(javaFile, lastModified, creationTime)) {
skipCount++
return FileVisitResult.CONTINUE
}
val localPath = file.toLocalPath()
filesystemDataProvider?.storeOrUpdateFileValue(
localPath,
lastModified,
javaFile.isDirectory,
folder
)
fileCount++
if (fileCount % 100 == 0) {
Log_OC.d(TAG, "Processed $fileCount files so far...")
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error processing file: $file", e)
errorCount++
}
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException?): FileVisitResult {
when (exc) {
is AccessDeniedException -> {
Log_OC.w(TAG, "Access denied: $file")
}
else -> {
Log_OC.e(TAG, "Failed to visit file: $file", exc)
}
}
errorCount++
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) {
Log_OC.e(TAG, "Error after visiting directory: $dir", exc)
errorCount++
}
return FileVisitResult.CONTINUE
}
}
)
Log_OC.d(
TAG,
"Scan complete for ${folder.localPath}: " +
"$fileCount files processed, $skipCount skipped, $errorCount errors"
)
} catch (e: Exception) {
Log_OC.e(TAG, "Error walking file tree: ${folder.localPath}", e)
}
return fileCount
}
}

View file

@ -0,0 +1,480 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.res.Resources
import androidx.core.app.NotificationCompat
import androidx.exifinterface.media.ExifInterface
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.database.entity.UploadEntity
import com.nextcloud.client.database.entity.toOCUpload
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.SubFolderRule
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.updateStatus
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.MediaFolderType
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
import com.owncloud.android.lib.common.OwnCloudAccount
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.activity.SettingsActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FileStorageUtils
import com.owncloud.android.utils.FilesSyncHelper
import com.owncloud.android.utils.MimeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Suppress("LongParameterList", "TooManyFunctions")
class AutoUploadWorker(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val syncedFolderProvider: SyncedFolderProvider,
private val backgroundJobManager: BackgroundJobManager,
private val repository: FileSystemRepository
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "🔄📤" + "AutoUpload"
const val OVERRIDE_POWER_SAVING = "overridePowerSaving"
const val CONTENT_URIS = "content_uris"
const val SYNCED_FOLDER_ID = "syncedFolderId"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD
private const val NOTIFICATION_ID = 266
}
private val helper = AutoUploadHelper()
private lateinit var syncedFolder: SyncedFolder
private val notificationManager by lazy {
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override suspend fun doWork(): Result {
return try {
val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1)
syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId)
?.takeIf { it.isEnabled } ?: return Result.failure()
// initial notification
val notification = createNotification(context.getString(R.string.upload_files))
updateForegroundInfo(notification)
/**
* Receives from [com.nextcloud.client.jobs.ContentObserverWork.checkAndTriggerAutoUpload]
*/
val contentUris = inputData.getStringArray(CONTENT_URIS)
if (canExitEarly(contentUris, syncFolderId)) {
return Result.retry()
}
collectFileChangesFromContentObserverWork(contentUris)
updateNotification()
uploadFiles(syncedFolder)
Log_OC.d(TAG, "${syncedFolder.remotePath} finished checking files.")
Result.success()
} catch (e: Exception) {
Log_OC.e(TAG, "❌ failed: ${e.message}")
Result.failure()
}
}
private fun updateNotification() {
getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) ->
val startNotification = createNotification(
context.getString(
R.string.auto_upload_worker_start_text,
localFolderName,
remoteFolderName
)
)
notificationManager.notify(NOTIFICATION_ID, startNotification)
}
}
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.uploads)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setSilent(true)
.build()
@Suppress("TooGenericExceptionCaught")
private fun getStartNotificationTitle(): Pair<String, String>? = try {
val localPath = syncedFolder.localPath
val remotePath = syncedFolder.remotePath
if (localPath.isBlank() || remotePath.isBlank()) {
null
} else {
try {
File(localPath).name to File(remotePath).name
} catch (_: Exception) {
null
}
}
} catch (_: Exception) {
null
}
@Suppress("ReturnCount")
private fun canExitEarly(contentUris: Array<String>?, syncedFolderID: Long): Boolean {
val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false)
if ((powerManagementService.isPowerSavingEnabled && !overridePowerSaving)) {
Log_OC.w(TAG, "⚡ Skipping: device is in power saving mode")
return true
}
if (syncedFolderID < 0) {
Log_OC.e(TAG, "invalid sync folder id")
return true
}
if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) {
Log_OC.w(TAG, "🚧 another worker is already running for $syncedFolderID")
return true
}
val totalScanInterval = syncedFolder.getTotalScanInterval(connectivityService, powerManagementService)
val currentTime = System.currentTimeMillis()
val passedScanInterval = totalScanInterval <= currentTime
Log_OC.d(TAG, "lastScanTimestampMs: " + syncedFolder.lastScanTimestampMs)
Log_OC.d(TAG, "totalScanInterval: $totalScanInterval")
Log_OC.d(TAG, "currentTime: $currentTime")
Log_OC.d(TAG, "passedScanInterval: $passedScanInterval")
if (!passedScanInterval && contentUris.isNullOrEmpty() && !overridePowerSaving) {
Log_OC.w(
TAG,
"skipped since started before scan interval and nothing todo: " + syncedFolder.localPath
)
return true
}
return false
}
/**
* Instead of scanning the entire local folder, optional content URIs can be passed to the worker
* to detect only the relevant changes.
*/
@Suppress("MagicNumber", "TooGenericExceptionCaught")
private suspend fun collectFileChangesFromContentObserverWork(contentUris: Array<String>?) = try {
withContext(Dispatchers.IO) {
if (contentUris.isNullOrEmpty()) {
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
} else {
val isContentUrisStored = FilesSyncHelper.insertChangedEntries(syncedFolder, contentUris)
if (!isContentUrisStored) {
Log_OC.w(
TAG,
"changed content uris not stored, fallback to insert all db entries to not lose files"
)
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
}
}
syncedFolder.lastScanTimestampMs = System.currentTimeMillis()
syncedFolderProvider.updateSyncFolder(syncedFolder)
}
} catch (e: Exception) {
Log_OC.d(TAG, "Exception collectFileChangesFromContentObserverWork: $e")
}
private fun prepareDateFormat(): SimpleDateFormat {
val currentLocale = context.resources.configuration.locales[0]
return SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale).apply {
timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id)
}
}
private fun getUserOrReturn(syncedFolder: SyncedFolder): User? {
val optionalUser = userAccountManager.getUser(syncedFolder.account)
if (!optionalUser.isPresent) {
Log_OC.w(TAG, "user not present")
return null
}
return optionalUser.get()
}
@Suppress("DEPRECATION")
private fun getUploadSettings(syncedFolder: SyncedFolder): Triple<Boolean, Boolean, Int> {
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val accountName = syncedFolder.account
return if (lightVersion) {
Log_OC.d(TAG, "light version is used")
val arbitraryDataProvider = ArbitraryDataProviderImpl(context)
val needsCharging = context.resources.getBoolean(R.bool.syncedFolder_light_on_charging)
val needsWifi = arbitraryDataProvider.getBooleanValue(
accountName,
SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI
)
val uploadActionString = context.resources.getString(R.string.syncedFolder_light_upload_behaviour)
val uploadAction = getUploadAction(uploadActionString)
Log_OC.d(TAG, "upload action is: $uploadAction")
Triple(needsCharging, needsWifi, uploadAction)
} else {
Log_OC.d(TAG, "not light version is used")
Triple(syncedFolder.isChargingOnly, syncedFolder.isWifiOnly, syncedFolder.uploadAction)
}
}
@Suppress("LongMethod", "DEPRECATION", "TooGenericExceptionCaught")
private suspend fun uploadFiles(syncedFolder: SyncedFolder) = withContext(Dispatchers.IO) {
val dateFormat = prepareDateFormat()
val user = getUserOrReturn(syncedFolder) ?: return@withContext
val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context)
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(ocAccount, context)
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val currentLocale = context.resources.configuration.locales[0]
var lastId = 0
while (true) {
val filePathsWithIds = repository.getFilePathsWithIds(syncedFolder, lastId)
if (filePathsWithIds.isEmpty()) {
Log_OC.w(TAG, "no more files to upload at lastId: $lastId")
break
}
Log_OC.d(TAG, "Processing batch: lastId=$lastId, count=${filePathsWithIds.size}")
filePathsWithIds.forEach { (path, id) ->
val file = File(path)
val localPath = file.absolutePath
val remotePath = getRemotePath(
file,
syncedFolder,
dateFormat,
lightVersion,
context.resources,
currentLocale
)
try {
var (uploadEntity, upload) = createEntityAndUpload(user, localPath, remotePath)
try {
// Insert/update to IN_PROGRESS state before starting upload
val generatedId = uploadsStorageManager.uploadDao.insertOrReplace(uploadEntity)
uploadEntity = uploadEntity.copy(id = generatedId.toInt())
upload.uploadId = generatedId
val operation = createUploadFileOperation(upload, user)
Log_OC.d(TAG, "🕒 uploading: $localPath, id: $generatedId")
val result = operation.execute(client)
uploadsStorageManager.updateStatus(uploadEntity, result.isSuccess)
if (result.isSuccess) {
repository.markFileAsUploaded(localPath, syncedFolder)
Log_OC.d(TAG, "✅ upload completed: $localPath")
} else {
Log_OC.e(
TAG,
"❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}"
)
}
} catch (e: Exception) {
uploadsStorageManager.updateStatus(
uploadEntity,
UploadsStorageManager.UploadStatus.UPLOAD_FAILED
)
Log_OC.e(
TAG,
"Exception during upload file, localPath: $localPath, remotePath: $remotePath," +
" exception: $e"
)
}
} catch (e: Exception) {
Log_OC.e(
TAG,
"Exception uploadFiles during creating entity and upload, localPath: $localPath, " +
"remotePath: $remotePath, exception: $e"
)
}
// update last id so upload can continue where it left
lastId = id
}
}
}
private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair<UploadEntity, OCUpload> {
val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder)
Log_OC.d(TAG, "creating oc upload for ${user.accountName}")
// Get existing upload or create new one
val uploadEntity = uploadsStorageManager.uploadDao.getUploadByAccountAndPaths(
localPath = localPath,
remotePath = remotePath,
accountName = user.accountName
)
val upload = (
uploadEntity?.toOCUpload(null) ?: OCUpload(
localPath,
remotePath,
user.accountName
)
).apply {
uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS
nameCollisionPolicy = syncedFolder.nameCollisionPolicy
isUseWifiOnly = needsWifi
isWhileChargingOnly = needsCharging
localAction = uploadAction
// Only set these for new uploads
if (uploadEntity == null) {
createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE
isCreateRemoteFolder = true
}
}
return upload.toUploadEntity() to upload
}
private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation(
uploadsStorageManager,
connectivityService,
powerManagementService,
user,
null,
upload,
upload.nameCollisionPolicy,
upload.localAction,
context,
upload.isUseWifiOnly,
upload.isWhileChargingOnly,
true,
FileDataStorageManager(user, context.contentResolver)
)
private fun getRemotePath(
file: File,
syncedFolder: SyncedFolder,
sFormatter: SimpleDateFormat,
lightVersion: Boolean,
resources: Resources,
currentLocale: Locale
): String {
val lastModificationTime = calculateLastModificationTime(file, syncedFolder, sFormatter)
val (remoteFolder, useSubfolders, subFolderRule) = if (lightVersion) {
Triple(
resources.getString(R.string.syncedFolder_remote_folder),
resources.getBoolean(R.bool.syncedFolder_light_use_subfolders),
SubFolderRule.YEAR_MONTH
)
} else {
Triple(
syncedFolder.remotePath,
syncedFolder.isSubfolderByDate,
syncedFolder.subfolderRule
)
}
return FileStorageUtils.getInstantUploadFilePath(
file,
currentLocale,
remoteFolder,
syncedFolder.localPath,
lastModificationTime,
useSubfolders,
subFolderRule
)
}
private fun hasExif(file: File): Boolean {
val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath)
return MimeType.JPEG.equals(mimeType, ignoreCase = true) || MimeType.TIFF.equals(mimeType, ignoreCase = true)
}
@Suppress("NestedBlockDepth")
private fun calculateLastModificationTime(
file: File,
syncedFolder: SyncedFolder,
formatter: SimpleDateFormat
): Long {
var lastModificationTime = file.lastModified()
if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) {
Log_OC.d(TAG, "calculateLastModificationTime exif found")
@Suppress("TooGenericExceptionCaught")
try {
val exifInterface = ExifInterface(file.absolutePath)
val exifDate = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
if (!exifDate.isNullOrBlank()) {
val pos = ParsePosition(0)
val dateTime = formatter.parse(exifDate, pos)
if (dateTime != null) {
lastModificationTime = dateTime.time
Log_OC.w(TAG, "calculateLastModificationTime calculatedTime is: $lastModificationTime")
} else {
Log_OC.w(TAG, "calculateLastModificationTime dateTime is empty")
}
} else {
Log_OC.w(TAG, "calculateLastModificationTime exifDate is empty")
}
} catch (e: Exception) {
Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage)
}
}
return lastModificationTime
}
private fun getUploadAction(action: String): Int = when (action) {
"LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
"LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_MOVE
"LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_DELETE
else -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
}
}

View file

@ -0,0 +1,66 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.client.database.dao.FileSystemDao
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.SyncedFolderUtils
import java.io.File
class FileSystemRepository(private val dao: FileSystemDao) {
companion object {
private const val TAG = "FilesystemRepository"
const val BATCH_SIZE = 50
}
@Suppress("NestedBlockDepth")
suspend fun getFilePathsWithIds(syncedFolder: SyncedFolder, lastId: Int): List<Pair<String, Int>> {
val syncedFolderId = syncedFolder.id.toString()
Log_OC.d(TAG, "Fetching candidate files for syncedFolderId = $syncedFolderId")
val entities = dao.getAutoUploadFilesEntities(syncedFolderId, BATCH_SIZE, lastId)
val filtered = mutableListOf<Pair<String, Int>>()
entities.forEach {
it.localPath?.let { path ->
val file = File(path)
if (!file.exists()) {
Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): $path")
} else if (!SyncedFolderUtils.isQualifiedFolder(file.parent)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): $path")
} else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.name)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified file): $path")
} else {
Log_OC.d(TAG, "Adding path to upload: $path")
if (it.id != null) {
filtered.add(path to it.id)
} else {
Log_OC.w(TAG, "cant adding path to upload, id is null")
}
}
}
}
return filtered
}
@Suppress("TooGenericExceptionCaught")
suspend fun markFileAsUploaded(localPath: String, syncedFolder: SyncedFolder) {
val syncedFolderIdStr = syncedFolder.id.toString()
try {
dao.markFileAsUploaded(localPath, syncedFolderIdStr)
Log_OC.d(TAG, "Marked file as uploaded: $localPath for syncedFolderId=$syncedFolderIdStr")
} catch (e: Exception) {
Log_OC.e(TAG, "Error marking file as uploaded: ${e.message}", e)
}
}
}