diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 531c4a67f9..3aedf5511f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -155,30 +155,31 @@ sentry { dependencies { - implementation(libs.infomaniak.core) - implementation(libs.infomaniak.core.auth) - implementation(libs.infomaniak.core.avatar) - implementation(libs.infomaniak.core.coil) - implementation(libs.infomaniak.core.crossapplogin) - implementation(libs.infomaniak.core.fragmentnavigation) - implementation(libs.infomaniak.core.inappreview) - implementation(libs.infomaniak.core.inappupdate) - implementation(libs.infomaniak.core.ksuite) - implementation(libs.infomaniak.core.ksuite.pro) - implementation(libs.infomaniak.core.ksuite.myksuite) - implementation(libs.infomaniak.core.ktor) - implementation(libs.infomaniak.core.matomo) - implementation(libs.infomaniak.core.network) - implementation(libs.infomaniak.core.recyclerview) - implementation(libs.infomaniak.core.sentry) - implementation(libs.infomaniak.core.thumbnails) - implementation(libs.infomaniak.core.twofactorauth.back) - implementation(libs.infomaniak.core.twofactorauth.front) - implementation(libs.infomaniak.core.ui) - implementation(libs.infomaniak.core.ui.compose.basics) - implementation(libs.infomaniak.core.ui.compose.margin) - implementation(libs.infomaniak.core.ui.compose.materialthemefromxml) - implementation(libs.infomaniak.core.ui.view.edgetoedge) + implementation(core.infomaniak.core.auth) + implementation(core.infomaniak.core.avatar) + implementation(core.infomaniak.core.coil) + implementation(core.infomaniak.core.common) + implementation(core.infomaniak.core.crossapplogin) + implementation(core.infomaniak.core.fragmentnavigation) + implementation(core.infomaniak.core.inappreview) + implementation(core.infomaniak.core.inappupdate) + implementation(core.infomaniak.core.ksuite) + implementation(core.infomaniak.core.ksuite.myksuite) + implementation(core.infomaniak.core.ksuite.pro) + implementation(core.infomaniak.core.ktor) + implementation(core.infomaniak.core.matomo) + implementation(core.infomaniak.core.network) + implementation(core.infomaniak.core.notifications) + implementation(core.infomaniak.core.recyclerview) + implementation(core.infomaniak.core.sentry) + implementation(core.infomaniak.core.thumbnails) + implementation(core.infomaniak.core.twofactorauth.back) + implementation(core.infomaniak.core.twofactorauth.front) + implementation(core.infomaniak.core.ui) + implementation(core.infomaniak.core.ui.compose.basics) + implementation(core.infomaniak.core.ui.compose.margin) + implementation(core.infomaniak.core.ui.compose.materialthemefromxml) + implementation(core.infomaniak.core.ui.view.edgetoedge) implementation(project(":Core:Legacy")) implementation(project(":Core:Legacy:AppLock")) diff --git a/app/src/main/java/com/infomaniak/drive/MainApplication.kt b/app/src/main/java/com/infomaniak/drive/MainApplication.kt index 9a543f8026..c10d7fae46 100644 --- a/app/src/main/java/com/infomaniak/drive/MainApplication.kt +++ b/app/src/main/java/com/infomaniak/drive/MainApplication.kt @@ -49,6 +49,7 @@ import com.infomaniak.core.legacy.utils.NotificationUtilsCore.Companion.PENDING_ import com.infomaniak.core.network.NetworkConfiguration import com.infomaniak.core.network.api.ApiController import com.infomaniak.core.network.networking.HttpClientConfig +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.core.sentry.SentryConfig.configureSentry import com.infomaniak.core.twofactorauth.back.TwoFactorAuthManager import com.infomaniak.drive.GeniusScanUtils.initGeniusScanSdk @@ -67,7 +68,6 @@ import com.infomaniak.drive.utils.AccountUtils import com.infomaniak.drive.utils.MyKSuiteDataUtils import com.infomaniak.drive.utils.NotificationUtils.buildGeneralNotification import com.infomaniak.drive.utils.NotificationUtils.initNotificationChannel -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -239,7 +239,7 @@ open class MainApplication : Application(), SingletonImageLoader.Factory, Defaul val notificationManagerCompat = NotificationManagerCompat.from(this) buildGeneralNotification(getString(R.string.refreshTokenError)).apply { setContentIntent(pendingIntent) - notificationManagerCompat.notifyCompat(this@MainApplication, hashCode, build()) + notificationManagerCompat.notifyCompat(hashCode, this) } applicationScope.launch { diff --git a/app/src/main/java/com/infomaniak/drive/data/api/UploadTask.kt b/app/src/main/java/com/infomaniak/drive/data/api/UploadTask.kt index 52e5c46b40..2871d83950 100644 --- a/app/src/main/java/com/infomaniak/drive/data/api/UploadTask.kt +++ b/app/src/main/java/com/infomaniak/drive/data/api/UploadTask.kt @@ -1,6 +1,6 @@ /* * Infomaniak kDrive - Android - * Copyright (C) 2022-2025 Infomaniak Network SA + * Copyright (C) 2022-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,6 +36,7 @@ import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.networking.HttpUtils import com.infomaniak.core.network.networking.ManualAuthorizationRequired import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.core.sentry.SentryLog import com.infomaniak.drive.data.api.ApiRepository.uploadEmptyFile import com.infomaniak.drive.data.api.ApiRoutes.uploadChunkUrl @@ -47,8 +48,6 @@ import com.infomaniak.drive.data.services.UploadWorker import com.infomaniak.drive.data.sync.UploadNotifications.progressPendingIntent import com.infomaniak.drive.utils.NotificationUtils.CURRENT_UPLOAD_ID import com.infomaniak.drive.utils.NotificationUtils.ELAPSED_TIME -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat -import com.infomaniak.drive.utils.NotificationUtils.uploadProgressNotification import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.onUpload @@ -93,6 +92,8 @@ class UploadTask( private val context: Context, private val uploadFile: UploadFile, private val setProgress: KSuspendFunction1, + private val notificationManagerCompat: NotificationManagerCompat, + private val uploadNotificationBuilder: NotificationCompat.Builder, ) { private val fileChunkSizeManager = FileChunkSizeManager() @@ -104,18 +105,17 @@ class UploadTask( it.tryEmit(Unit) } - private lateinit var notificationManagerCompat: NotificationManagerCompat - private lateinit var uploadNotification: NotificationCompat.Builder private var uploadNotificationElapsedTime = ELAPSED_TIME private var uploadNotificationStartTime = 0L suspend fun start(): Boolean { - notificationManagerCompat = NotificationManagerCompat.from(context) - - uploadNotification = context.uploadProgressNotification() - uploadNotification.apply { + uploadNotificationBuilder.apply { setContentTitle(uploadFile.fileName) - notificationManagerCompat.notifyCompat(context, CURRENT_UPLOAD_ID, build()) + setOngoing(true) + setContentText(null) + setProgress(0, 0, false) + setSmallIcon(android.R.drawable.stat_sys_upload) + notificationManagerCompat.notifyCompat(CURRENT_UPLOAD_ID, this) } try { @@ -146,7 +146,6 @@ class UploadTask( Sentry.captureException(exception) { scope -> scope.level = SentryLevel.WARNING } } catch (exception: UploadNotTerminated) { SentryLog.w(TAG, "upload not terminated", exception) - notificationManagerCompat.cancel(CURRENT_UPLOAD_ID) Sentry.captureException(exception) { scope -> scope.level = SentryLevel.WARNING } } catch (exception: QuotaExceededException) { if (UploadFile.getAppSyncSettings()?.driveId == uploadFile.driveId) { @@ -156,8 +155,6 @@ class UploadTask( } catch (exception: Exception) { exception.printStackTrace() throw exception - } finally { - notificationManagerCompat.cancel(CURRENT_UPLOAD_ID) } return false } @@ -286,16 +283,14 @@ class UploadTask( } private suspend fun finishUpload(uri: Uri) { - uploadNotification.apply { + uploadNotificationBuilder.apply { setOngoing(false) - setContentText("100%") setSmallIcon(android.R.drawable.stat_sys_upload_done) setProgress(0, 0, false) - notificationManagerCompat.notifyCompat(context, CURRENT_UPLOAD_ID, build()) + notificationManagerCompat.notifyCompat(CURRENT_UPLOAD_ID, this) } shareProgress(100, true) UploadFile.uploadFinished(uri) - notificationManagerCompat.cancel(CURRENT_UPLOAD_ID) } private suspend fun uploadChunk( @@ -409,7 +404,6 @@ class UploadTask( } } val bodyResponse = String(bytes) - notificationManagerCompat.cancel(CURRENT_UPLOAD_ID) val apiResponse = try { gson.fromJson(bodyResponse, ApiResponse::class.java)!! // Might be empty when http 502 Bad gateway happens } catch (_: Exception) { @@ -432,11 +426,11 @@ class UploadTask( currentCoroutineContext().ensureActive() if (uploadNotificationElapsedTime >= ELAPSED_TIME) { - uploadNotification.apply { - setContentIntent(uploadFile.progressPendingIntent(context)) + uploadNotificationBuilder.apply { + setContentIntent(uploadFile.progressPendingIntent()) setContentText("${progress}%") setProgress(100, progress, false) - notificationManagerCompat.notifyCompat(context, CURRENT_UPLOAD_ID, build()) + notificationManagerCompat.notifyCompat(CURRENT_UPLOAD_ID, this) uploadNotificationStartTime = System.currentTimeMillis() uploadNotificationElapsedTime = 0L } diff --git a/app/src/main/java/com/infomaniak/drive/data/documentprovider/CloudStorageProvider.kt b/app/src/main/java/com/infomaniak/drive/data/documentprovider/CloudStorageProvider.kt index 76488834a0..f5bd4f59db 100644 --- a/app/src/main/java/com/infomaniak/drive/data/documentprovider/CloudStorageProvider.kt +++ b/app/src/main/java/com/infomaniak/drive/data/documentprovider/CloudStorageProvider.kt @@ -40,6 +40,7 @@ import com.infomaniak.core.common.cancellable import com.infomaniak.core.legacy.utils.NotificationUtilsCore import com.infomaniak.core.network.api.ApiController import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.core.sentry.SentryLog import com.infomaniak.drive.R import com.infomaniak.drive.data.api.ApiRepository @@ -57,7 +58,6 @@ import com.infomaniak.drive.utils.DownloadOfflineFileManager import com.infomaniak.drive.utils.IOFile import com.infomaniak.drive.utils.NotificationUtils.buildGeneralNotification import com.infomaniak.drive.utils.NotificationUtils.cancelNotification -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat import com.infomaniak.drive.utils.SyncUtils.syncImmediately import com.infomaniak.drive.utils.Utils import com.infomaniak.drive.utils.copyToCancellable @@ -722,7 +722,7 @@ class CloudStorageProvider : DocumentsProvider() { NotificationUtilsCore.PENDING_INTENT_FLAGS, ), ) - NotificationManagerCompat.from(context).notifyCompat(context, syncPermissionNotifId, build()) + NotificationManagerCompat.from(context).notifyCompat(syncPermissionNotifId, this) } } } diff --git a/app/src/main/java/com/infomaniak/drive/data/services/BulkOperationWorker.kt b/app/src/main/java/com/infomaniak/drive/data/services/BulkOperationWorker.kt index 763aafff03..c82244fd17 100644 --- a/app/src/main/java/com/infomaniak/drive/data/services/BulkOperationWorker.kt +++ b/app/src/main/java/com/infomaniak/drive/data/services/BulkOperationWorker.kt @@ -1,6 +1,6 @@ /* * Infomaniak kDrive - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,15 +26,15 @@ import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.Observer -import androidx.work.ForegroundInfo import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.google.common.util.concurrent.ListenableFuture import com.infomaniak.core.legacy.utils.Utils.createRefreshTimer +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.drive.data.models.ActionProgress import com.infomaniak.drive.data.models.BulkOperationType import com.infomaniak.drive.data.models.MqttNotification -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat +import com.infomaniak.drive.utils.ForegroundInfoExt import java.util.Date import java.util.UUID @@ -63,17 +63,16 @@ class BulkOperationWorker(context: Context, workerParams: WorkerParameters) : Li setContentTitle(applicationContext.getString(bulkOperationType.title, 0, totalFiles)) } - if (SDK_INT >= 29) { - val notification = createNotificationBuilder().apply { - if (SDK_INT >= 31) { - foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE - } - }.build() - setForegroundAsync(ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)) - } else { - setForegroundAsync(ForegroundInfo(notificationId, createNotificationBuilder().build())) - } + val notification = createNotificationBuilder().apply { + if (SDK_INT >= 31) { + foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE + } + }.build() + val foregroundInfo = ForegroundInfoExt.build(notificationId, notification) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + setForegroundAsync(foregroundInfo) lastReception = Date() return CallbackToFutureAdapter.getFuture { completer -> @@ -99,9 +98,8 @@ class BulkOperationWorker(context: Context, workerParams: WorkerParameters) : Li onOperationFinished() } else { notificationManagerCompat.notifyCompat( - context = applicationContext, notificationId = notificationId, - build = createNotificationBuilder(notification.progress).build() + builder = createNotificationBuilder(notification.progress) ) } } diff --git a/app/src/main/java/com/infomaniak/drive/data/services/UploadWorker.kt b/app/src/main/java/com/infomaniak/drive/data/services/UploadWorker.kt index 5c8b352652..c7727bd86c 100644 --- a/app/src/main/java/com/infomaniak/drive/data/services/UploadWorker.kt +++ b/app/src/main/java/com/infomaniak/drive/data/services/UploadWorker.kt @@ -25,6 +25,7 @@ import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.provider.MediaStore import android.provider.OpenableColumns +import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toFile import androidx.lifecycle.LiveData @@ -36,11 +37,13 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkerParameters +import com.infomaniak.core.common.autoCancelScope import com.infomaniak.core.legacy.utils.calculateFileSize import com.infomaniak.core.legacy.utils.getFileName import com.infomaniak.core.legacy.utils.getFileSize import com.infomaniak.core.legacy.utils.hasPermissions import com.infomaniak.core.network.api.ApiController.gson +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.core.sentry.SentryLog import com.infomaniak.drive.R import com.infomaniak.drive.data.api.FileChunkSizeManager.AllowedFileSizeExceededException @@ -52,16 +55,18 @@ import com.infomaniak.drive.data.models.UploadFile import com.infomaniak.drive.data.models.UploadFile.Companion.getRealmInstance import com.infomaniak.drive.data.services.UploadWorkerErrorHandling.runUploadCatching import com.infomaniak.drive.data.sync.UploadNotifications +import com.infomaniak.drive.data.sync.UploadNotifications.appendBigDescription import com.infomaniak.drive.data.sync.UploadNotifications.showUploadedFilesNotification import com.infomaniak.drive.data.sync.UploadNotifications.syncSettingsActivityPendingIntent import com.infomaniak.drive.utils.DrivePermissions +import com.infomaniak.drive.utils.ForegroundInfoExt import com.infomaniak.drive.utils.MediaFoldersProvider import com.infomaniak.drive.utils.MediaFoldersProvider.IMAGES_BUCKET_ID import com.infomaniak.drive.utils.MediaFoldersProvider.VIDEO_BUCKET_ID import com.infomaniak.drive.utils.NotificationUtils +import com.infomaniak.drive.utils.NotificationUtils.UPLOAD_SERVICE_ID import com.infomaniak.drive.utils.NotificationUtils.buildGeneralNotification -import com.infomaniak.drive.utils.NotificationUtils.cancelNotification -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat +import com.infomaniak.drive.utils.NotificationUtils.uploadProgressNotification import com.infomaniak.drive.utils.SyncUtils import com.infomaniak.drive.utils.getAvailableMemory import com.infomaniak.drive.utils.uri @@ -72,11 +77,12 @@ import io.sentry.SentryLevel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import splitties.init.appCtx import splitties.systemservices.connectivityManager import java.util.Date @@ -91,9 +97,11 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor var currentUploadFile: UploadFile? = null var currentUploadTask: UploadTask? = null var uploadedCount = 0 - private var pendingCount = 0 - + private val pendingUploadCounter: MutableStateFlow = MutableStateFlow(0) private val readMediaPermissions = DrivePermissions.permissionsFor(DrivePermissions.Type.ReadingMediaForSync).toTypedArray() + private val notificationManagerCompat by lazy { NotificationManagerCompat.from(applicationContext) } + private val uploadNotificationBuilder by lazy { applicationContext.uploadProgressNotification() } + private val currentUploadsNotificationBuilder by lazy { UploadNotifications.prepareCurrentUploadNotification() } override suspend fun doWork(): Result { @@ -156,7 +164,7 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor } override suspend fun getForegroundInfo(): ForegroundInfo { - val pendingCount = if (this.pendingCount > 0) this.pendingCount else UploadFile.getAllPendingUploadsCount() + val pendingCount = pendingUploadCounter.value.takeIf { it > 0 } ?: UploadFile.getAllPendingUploadsCount() return progressForegroundInfo(pendingCount) } @@ -182,37 +190,29 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor return null } - private suspend fun uploadPendingFiles(): Result = withContext(Dispatchers.IO) { - var uploadFiles: List - - getRealmInstance().use { realm -> - uploadFiles = UploadFile.getAllPendingUploads(realm) - pendingCount = uploadFiles.size - - if (pendingCount > 0) applicationContext.cancelNotification(NotificationUtils.UPLOAD_STATUS_ID) + private suspend fun uploadPendingFiles(): Result = autoCancelScope { + val notSyncFiles = mutableListOf() + val uploadFiles: List = retrievePendingFiles() - checkUploadCountReliability(realm) - } + if (uploadFiles.isNotEmpty()) { + initUploadPendingCounter(uploadFiles.size) - SentryLog.d(TAG, "uploadPendingFiles> upload for ${uploadFiles.count()}") - val notSyncFiles = mutableListOf() - for ((index, fileToUpload) in uploadFiles.withIndex()) { - val isLastFile = index == uploadFiles.lastIndex - if (fileToUpload.canUpload()) { - uploadFile(fileToUpload, isLastFile) - } else { - notSyncFiles += fileToUpload + for ((index, fileToUpload) in uploadFiles.withIndex()) { + val isLastFile = index == uploadFiles.lastIndex + if (fileToUpload.canUpload()) { + uploadFile(fileToUpload, isLastFile) + } else { + notSyncFiles += fileToUpload + } + pendingUploadCounter.dec() + // Stop recursion if all files have been processed and there are only errors. + val allNotUploadedCount = failedNamesMap.count() + notSyncFiles.count() + if (isLastFile && allNotUploadedCount == UploadFile.getAllPendingUploadsCount()) break + // If there is a new file during the sync and it has priority (ex: Manual uploads), + // then we start again in order to process the priority files first. + if (fileToUpload.isSync() && UploadFile.getAllPendingPriorityFilesCount() > 0) return@autoCancelScope uploadPendingFiles() } - pendingCount-- - - // Stop recursion if all files have been processed and there are only errors. - val allNotUploadedCount = failedNamesMap.count() + notSyncFiles.count() - if (isLastFile && allNotUploadedCount == UploadFile.getAllPendingUploadsCount()) break - // If there is a new file during the sync and it has priority (ex: Manual uploads), - // then we start again in order to process the priority files first. - if (fileToUpload.isSync() && UploadFile.getAllPendingPriorityFilesCount() > 0) return@withContext uploadPendingFiles() } - uploadedCount = successCount SentryLog.d(TAG, "uploadPendingFiles: finish with $uploadedCount uploaded") @@ -234,6 +234,24 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor else -> true } + private fun retrievePendingFiles(): List { + return getRealmInstance().use { realm -> + UploadFile.getAllPendingUploads(realm).also { + checkUploadCountReliability(realm, it.size) + } + } + } + + private fun CoroutineScope.initUploadPendingCounter(size: Int) { + removePreviousStatusNotification() + launch { updatePendingUploadCounterNotification(size) } + SentryLog.d(TAG, "uploadPendingFiles> upload for $size") + } + + private fun removePreviousStatusNotification() { + notificationManagerCompat.cancel(NotificationUtils.UPLOAD_STATUS_ID) + } + private suspend fun uploadFile(uploadFile: UploadFile, isLastFile: Boolean) { SentryLog.d(TAG, "uploadFile> size: ${uploadFile.fileSize}") @@ -261,7 +279,7 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor } } - private fun checkUploadCountReliability(realm: Realm) { + private fun checkUploadCountReliability(realm: Realm, pendingCount: Int) { val allPendingUploadsCount = UploadFile.getAllPendingUploadsCount(realm) if (allPendingUploadsCount != pendingCount) { val allPendingUploadsWithoutPriorityCount = UploadFile.getAllPendingUploadsWithoutPriorityCount(realm) @@ -282,8 +300,6 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor val uri = getUriObject() currentUploadFile = this@upload - applicationContext.cancelNotification(NotificationUtils.CURRENT_UPLOAD_ID) - updateUploadCountNotification() try { if (isSchemeFile()) { @@ -347,7 +363,13 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor SentryLog.d(TAG, "startUploadFile (size: $fileSize)") - return UploadTask(context = applicationContext, uploadFile = this, setProgress = ::setProgress).run { + return UploadTask( + context = applicationContext, + uploadFile = this, + setProgress = ::setProgress, + notificationManagerCompat = notificationManagerCompat, + uploadNotificationBuilder = uploadNotificationBuilder + ).run { currentUploadTask = this start().also { isUploaded -> if (isUploaded) { @@ -361,32 +383,33 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor } } - private var uploadCountNotificationJob: Job? = null - private fun CoroutineScope.updateUploadCountNotification() { - uploadCountNotificationJob?.cancel() - uploadCountNotificationJob = launch { - // We wait a little otherwise it is too fast and the notification may not be updated - delay(NotificationUtils.ELAPSED_TIME) - val foregroundInfo = progressForegroundInfo(pendingCount) - setForegroundAsync(foregroundInfo) + private suspend fun updatePendingUploadCounterNotification(numberPendingUpload: Int) { + pendingUploadCounter.emit(numberPendingUpload) + setForeground(progressForegroundInfo(numberPendingUpload)) + + pendingUploadCounter.collect { pendingCount -> + notificationManagerCompat.notifyCompat( + notificationId = UPLOAD_SERVICE_ID, + builder = currentUploadsNotificationBuilder.appendPendingCount(pendingCount) + ) } } - private fun progressForegroundInfo(pendingCount: Int): ForegroundInfo { - val notification = UploadNotifications.getCurrentUploadNotification(applicationContext, pendingCount).build() - val foregroundInfo = when { - SDK_INT >= 29 -> { - ForegroundInfo( - /* notificationId = */ NotificationUtils.UPLOAD_SERVICE_ID, - /* notification = */ notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } - else -> { - ForegroundInfo(NotificationUtils.UPLOAD_SERVICE_ID, notification) - } + private fun progressForegroundInfo(count: Int): ForegroundInfo { + val notification = currentUploadsNotificationBuilder.appendPendingCount(count).build() + return ForegroundInfoExt.build(notificationId = UPLOAD_SERVICE_ID, notification = notification) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC } - return foregroundInfo + } + + private fun NotificationCompat.Builder.appendPendingCount(pendingCount: Int): NotificationCompat.Builder { + return appendBigDescription( + appCtx.resources.getQuantityString( + R.plurals.uploadInProgressNumberFile, + pendingCount, + pendingCount + ) + ) } private fun UploadFile.handleException(exception: Exception) { @@ -561,6 +584,8 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor private fun isMeteredNetwork() = runCatching { connectivityManager.isActiveNetworkMetered }.getOrDefault(true) + private fun MutableStateFlow.dec() = update { it.dec() } + companion object { const val TAG = "upload_worker" const val PERIODIC_TAG = "upload_worker_periodic" @@ -599,7 +624,7 @@ class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWor buildGeneralNotification(getString(R.string.noSyncFolderNotificationTitle)).apply { setContentText(getString(R.string.noSyncFolderNotificationDescription)) setContentIntent(pendingIntent) - notificationManagerCompat.notifyCompat(applicationContext, NotificationUtils.SYNC_CONFIG_ID, this.build()) + notificationManagerCompat.notifyCompat(NotificationUtils.SYNC_CONFIG_ID, this) } } diff --git a/app/src/main/java/com/infomaniak/drive/data/sync/UploadNotifications.kt b/app/src/main/java/com/infomaniak/drive/data/sync/UploadNotifications.kt index a94de32308..d8f783ebd0 100644 --- a/app/src/main/java/com/infomaniak/drive/data/sync/UploadNotifications.kt +++ b/app/src/main/java/com/infomaniak/drive/data/sync/UploadNotifications.kt @@ -1,6 +1,6 @@ /* * Infomaniak kDrive - Android - * Copyright (C) 2022-2025 Infomaniak Network SA + * Copyright (C) 2022-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.infomaniak.core.legacy.utils.NotificationUtilsCore.Companion.PENDING_INTENT_FLAGS import com.infomaniak.core.legacy.utils.clearStack +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.drive.R import com.infomaniak.drive.data.cache.DriveInfosController import com.infomaniak.drive.data.cache.FileController @@ -35,36 +36,19 @@ import com.infomaniak.drive.ui.MainActivity import com.infomaniak.drive.ui.menu.settings.SyncSettingsActivity import com.infomaniak.drive.utils.NotificationUtils import com.infomaniak.drive.utils.NotificationUtils.UPLOAD_SERVICE_ID -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat import com.infomaniak.drive.utils.NotificationUtils.uploadNotification +import splitties.init.appCtx import java.util.UUID object UploadNotifications { const val NOTIFICATION_FILES_LIMIT = 5 - fun getCurrentUploadNotification(context: Context, pendingCount: Int): NotificationCompat.Builder { - val pendingTitle = context.getString(R.string.uploadInProgressTitle) - val pendingDescription = context.resources.getQuantityString( - R.plurals.uploadInProgressNumberFile, - pendingCount, - pendingCount + fun prepareCurrentUploadNotification(): NotificationCompat.Builder { + return getNotificationBuilder( + title = appCtx.getString(R.string.uploadInProgressTitle), + contentIntent = buildLaunchActivityPendingIntent(UPLOAD_SERVICE_ID) ) - val intent = Intent(context, LaunchActivity::class.java).clearStack() - val contentIntent = PendingIntent.getActivity(context, UPLOAD_SERVICE_ID, intent, PENDING_INTENT_FLAGS) - return getNotificationBuilder(context, pendingTitle, pendingDescription, contentIntent) - } - - fun setupCurrentUploadNotification(context: Context, pendingCount: Int) { - val pendingTitle = context.getString(R.string.uploadInProgressTitle) - val pendingDescription = context.resources.getQuantityString( - R.plurals.uploadInProgressNumberFile, - pendingCount, - pendingCount - ) - val intent = Intent(context, LaunchActivity::class.java).clearStack() - val contentIntent = PendingIntent.getActivity(context, UPLOAD_SERVICE_ID, intent, PENDING_INTENT_FLAGS) - showNotification(context, pendingTitle, pendingDescription, UPLOAD_SERVICE_ID, contentIntent) } fun UploadFile.networkErrorNotification(context: Context) { @@ -89,7 +73,6 @@ object UploadNotifications { } showNotification( - context = context, title = context.getString(R.string.uploadErrorTitle), description = context.getString(description), notificationId = NotificationUtils.UPLOAD_STATUS_ID, @@ -118,21 +101,19 @@ object UploadNotifications { val title = if (isTechnicalMaintenance) R.plurals.driveMaintenanceTitle else R.plurals.driveBlockedTitle val description = context.resources.getQuantityString(title, 1, drive?.name) showNotification( - context = context, title = context.getString(R.string.uploadInterruptedErrorTitle), description = description, notificationId = NotificationUtils.UPLOAD_STATUS_ID, - contentIntent = progressPendingIntent(context) + contentIntent = progressPendingIntent() ) } fun UploadFile.foregroundServiceQuotaNotification(context: Context) { showNotification( - context = context, title = context.getString(R.string.uploadPausedTitle), description = context.getString(R.string.uploadPausedDescription), notificationId = NotificationUtils.UPLOAD_STATUS_ID, - contentIntent = progressPendingIntent(context), + contentIntent = progressPendingIntent(), ) } @@ -142,7 +123,6 @@ object UploadNotifications { Intent(context, MainActivity::class.java).clearStack(), PENDING_INTENT_FLAGS ) showNotification( - context = context, title = context.getString(R.string.uploadErrorTitle), description = context.getString(R.string.uploadPermissionError), notificationId = NotificationUtils.UPLOAD_STATUS_ID, @@ -187,11 +167,10 @@ object UploadNotifications { val titleResId = if (successCount > 0) R.string.allUploadFinishedTitle else R.string.uploadErrorTitle showNotification( - context = context, title = context.getString(titleResId), description = description, notificationId = NotificationUtils.UPLOAD_STATUS_ID, - contentIntent = progressPendingIntent(context), + contentIntent = progressPendingIntent(), locateButton = true, ) } @@ -214,7 +193,6 @@ object UploadNotifications { fun showCancelledByUserNotification(context: Context) { showNotification( - context = context, title = context.getString(R.string.uploadCancelTitle), description = context.getString(R.string.uploadCancelDescription), notificationId = NotificationUtils.UPLOAD_STATUS_ID @@ -222,66 +200,68 @@ object UploadNotifications { } private fun showNotification( - context: Context, title: String, description: String, notificationId: Int, contentIntent: PendingIntent? = null, locateButton: Boolean = false ) { - val notificationManagerCompat = NotificationManagerCompat.from(context) - val notificationBuilder = getNotificationBuilder(context, title, description, contentIntent, locateButton) - notificationManagerCompat.notifyCompat(context, notificationId, notificationBuilder.build()) + val notificationManagerCompat = NotificationManagerCompat.from(appCtx) + val notificationBuilder = getNotificationBuilder(title, contentIntent, locateButton).appendBigDescription(description) + notificationManagerCompat.notifyCompat(notificationId, notificationBuilder) } private fun getNotificationBuilder( - context: Context, title: String, - description: String, contentIntent: PendingIntent? = null, locateButton: Boolean = false ): NotificationCompat.Builder { - return context.uploadNotification().apply { + return appCtx.uploadNotification().apply { setTicker(title) setAutoCancel(true) setContentTitle(title) - setStyle(NotificationCompat.BigTextStyle().bigText(description)) setContentIntent(contentIntent) if (locateButton) { addAction( - NotificationCompat.Action(R.drawable.ic_export, context.getString(R.string.locateButton), contentIntent) + NotificationCompat.Action(R.drawable.ic_export, appCtx.getString(R.string.locateButton), contentIntent) ) } } } + fun NotificationCompat.Builder.appendBigDescription(description: String): NotificationCompat.Builder { + return setStyle(NotificationCompat.BigTextStyle().bigText(description)) + } + private fun UploadFile.uploadInterruptedNotification( context: Context, @StringRes messageRes: Int, @StringRes titleRes: Int = R.string.uploadInterruptedErrorTitle ) { showNotification( - context = context, title = context.getString(titleRes), description = context.getString(messageRes), notificationId = NotificationUtils.UPLOAD_STATUS_ID, - contentIntent = progressPendingIntent(context) + contentIntent = progressPendingIntent() ) } - fun UploadFile.progressPendingIntent(context: Context): PendingIntent { - val intent = Intent(context, LaunchActivity::class.java).clearStack().apply { - putExtras( - LaunchActivityArgs( - destinationUserId = userId, - destinationDriveId = driveId, - destinationRemoteFolderId = getDestinationFolderId(this@progressPendingIntent) - ).toBundle() - ) - } + fun UploadFile.progressPendingIntent(): PendingIntent = buildLaunchActivityPendingIntent( + requestCode = NotificationUtils.UPLOAD_STATUS_ID, + args = LaunchActivityArgs( + destinationUserId = userId, + destinationDriveId = driveId, + destinationRemoteFolderId = getDestinationFolderId(this@progressPendingIntent) + ) + ) - return PendingIntent.getActivity(context, NotificationUtils.UPLOAD_STATUS_ID, intent, PENDING_INTENT_FLAGS) - } + private fun buildLaunchActivityPendingIntent(requestCode: Int, args: LaunchActivityArgs? = null): PendingIntent = + PendingIntent.getActivity(appCtx, requestCode, launchActivityIntent(args), PENDING_INTENT_FLAGS) + + private fun launchActivityIntent(args: LaunchActivityArgs? = null) = + Intent(appCtx, LaunchActivity::class.java) + .clearStack() + .apply { args?.let { putExtras(args.toBundle()) } } fun Context.syncSettingsActivityPendingIntent(): PendingIntent { return PendingIntent.getActivity( diff --git a/app/src/main/java/com/infomaniak/drive/utils/DownloadOfflineFileManager.kt b/app/src/main/java/com/infomaniak/drive/utils/DownloadOfflineFileManager.kt index 6823dfe2a5..d6aae676fe 100644 --- a/app/src/main/java/com/infomaniak/drive/utils/DownloadOfflineFileManager.kt +++ b/app/src/main/java/com/infomaniak/drive/utils/DownloadOfflineFileManager.kt @@ -36,6 +36,7 @@ import com.infomaniak.core.network.networking.HttpUtils import com.infomaniak.core.network.networking.ManualAuthorizationRequired import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.core.network.utils.await +import com.infomaniak.core.notifications.notifyCompat import com.infomaniak.core.sentry.SentryLog import com.infomaniak.drive.R import com.infomaniak.drive.data.api.ApiRepository @@ -50,7 +51,6 @@ import com.infomaniak.drive.data.services.BulkDownloadWorker import com.infomaniak.drive.data.services.DownloadWorker import com.infomaniak.drive.utils.MediaUtils.isMedia import com.infomaniak.drive.utils.NotificationUtils.downloadProgressNotification -import com.infomaniak.drive.utils.NotificationUtils.notifyCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.invoke @@ -280,7 +280,7 @@ class DownloadOfflineFileManager( setContentTitle(contentTitle) setContentText(contentText) setProgress(100, progressPercent, false) - notificationManagerCompat.notifyCompat(context, downloadNotification.id, build()) + notificationManagerCompat.notifyCompat(downloadNotification.id, this) } } diff --git a/app/src/main/java/com/infomaniak/drive/utils/ForegroundInfoExt.kt b/app/src/main/java/com/infomaniak/drive/utils/ForegroundInfoExt.kt new file mode 100644 index 0000000000..0489a451e7 --- /dev/null +++ b/app/src/main/java/com/infomaniak/drive/utils/ForegroundInfoExt.kt @@ -0,0 +1,38 @@ +/* + * Infomaniak kDrive - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.drive.utils + +import android.app.Notification +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import androidx.work.ForegroundInfo + + +object ForegroundInfoExt { + fun build( + notificationId: Int, + notification: Notification, + foregroundServiceTypeProvider: () -> Int, + ): ForegroundInfo { + return if (SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(notificationId, notification, foregroundServiceTypeProvider()) + } else { + ForegroundInfo(notificationId, notification) + } + } +} diff --git a/app/src/main/java/com/infomaniak/drive/utils/NotificationUtils.kt b/app/src/main/java/com/infomaniak/drive/utils/NotificationUtils.kt index 30d6f10cbc..38d71c77eb 100644 --- a/app/src/main/java/com/infomaniak/drive/utils/NotificationUtils.kt +++ b/app/src/main/java/com/infomaniak/drive/utils/NotificationUtils.kt @@ -64,7 +64,6 @@ object NotificationUtils : NotificationUtilsCore() { setTicker(ticker) setSmallIcon(icon) setAutoCancel(true) - setContentText("0%") setOnlyAlertOnce(true) setProgress(100, 0, true) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7155cb03cc..aa8d59f144 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,30 +40,6 @@ firebase-messaging-ktx = { module = "com.google.firebase:firebase-messaging-ktx" google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" } gravity-snap-helper = { module = "com.github.rubensousa:gravitysnaphelper", version.ref = "gravitySnapHelper" } gs-sdk = { module = "com.geniusscansdk:gssdk", version.ref = "gsSdk" } -infomaniak-core = { module = "com.infomaniak.core:Common" } -infomaniak-core-auth = { module = "com.infomaniak.core:Auth" } -infomaniak-core-avatar = { module = "com.infomaniak.core:Avatar" } -infomaniak-core-coil = { module = "com.infomaniak.core:Coil" } -infomaniak-core-crossapplogin = { module = "com.infomaniak.core:CrossAppLogin.Front" } -infomaniak-core-fragmentnavigation = { module = "com.infomaniak.core:FragmentNavigation" } -infomaniak-core-inappreview = { module = "com.infomaniak.core:InAppReview" } -infomaniak-core-inappupdate = { module = "com.infomaniak.core:InAppUpdate" } -infomaniak-core-ksuite = { module = "com.infomaniak.core:KSuite" } -infomaniak-core-ksuite-pro = { module = "com.infomaniak.core:KSuite.KSuitePro" } -infomaniak-core-ksuite-myksuite = { module = "com.infomaniak.core:KSuite.MyKSuite" } -infomaniak-core-ktor = { module = "com.infomaniak.core:Ktor" } -infomaniak-core-matomo = { module = "com.infomaniak.core:Matomo" } -infomaniak-core-network = { module = "com.infomaniak.core:Network" } -infomaniak-core-recyclerview = { module = "com.infomaniak.core:RecyclerView" } -infomaniak-core-sentry = { module = "com.infomaniak.core:Sentry" } -infomaniak-core-thumbnails = { module = "com.infomaniak.core:Thumbnails" } -infomaniak-core-twofactorauth-back = { module = "com.infomaniak.core:TwoFactorAuth.Back.WithUserDb" } -infomaniak-core-twofactorauth-front = { module = "com.infomaniak.core:TwoFactorAuth.Front" } -infomaniak-core-ui = { module = "com.infomaniak.core:Ui" } -infomaniak-core-ui-compose-basics = { module = "com.infomaniak.core:Ui.Compose.Basics" } -infomaniak-core-ui-compose-margin = { module = "com.infomaniak.core:Ui.Compose.Margin" } -infomaniak-core-ui-compose-materialthemefromxml = { module = "com.infomaniak.core:Ui.Compose.MaterialThemeFromXml" } -infomaniak-core-ui-view-edgetoedge = { module = "com.infomaniak.core:Ui.View.EdgeToEdge" } kotlin-faker = { module = "io.github.serpro69:kotlin-faker", version.ref = "kotlinFaker" } material-date-time-picker = { module = "com.wdullaer:materialdatetimepicker", version.ref = "materialDateTimePicker" } mock-web-server = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockWebServer" }