diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1fb83d54..7b0e01a21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -257,6 +257,7 @@ dependencies { implementation(core.androidx.work.runtime.ktx) implementation(core.kotlinx.serialization.json) implementation(core.splitties.toast) + implementation(core.splitties.preferences) implementation(core.okhttp) // Test diff --git a/app/src/main/java/com/infomaniak/swisstransfer/di/ApplicationModule.kt b/app/src/main/java/com/infomaniak/swisstransfer/di/ApplicationModule.kt index cab0f1957..a8fccf4a2 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/di/ApplicationModule.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/di/ApplicationModule.kt @@ -19,6 +19,7 @@ package com.infomaniak.swisstransfer.di import android.app.Application import android.content.Context +import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.work.WorkManager import com.infomaniak.core.appintegrity.AppIntegrityManager @@ -75,9 +76,15 @@ object ApplicationModule { return InfomaniakLogin( context = appContext, loginUrl = "${LOGIN_ENDPOINT_URL}/", - appUID = ConfigUtils.safePackage, + appUID = ConfigUtils.safePackage.substringBefore(".preprod"), clientID = BuildConfig.CLIENT_ID, accessType = null, + sentryCallback = { message, extras -> + //TODO[Sentry]: Should we forward this to Sentry, or leave it only in logcat for debug? + val tag = "LoginIssue" + Log.e(tag, message) + Log.e(tag, extras.toString()) + } ) } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt b/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt index e4711e79a..b2584e158 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt @@ -17,20 +17,25 @@ */ package com.infomaniak.swisstransfer.di +import com.infomaniak.core.appintegrity.AppIntegrityManager +import com.infomaniak.core.network.ApiEnvironment import com.infomaniak.multiplatform_swisstransfer.SharedApiUrlCreator import com.infomaniak.multiplatform_swisstransfer.SwissTransferInjection -import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment import com.infomaniak.multiplatform_swisstransfer.managers.AccountManager import com.infomaniak.multiplatform_swisstransfer.managers.AppSettingsManager import com.infomaniak.multiplatform_swisstransfer.managers.FileManager import com.infomaniak.multiplatform_swisstransfer.managers.InMemoryUploadManager import com.infomaniak.multiplatform_swisstransfer.managers.TransferManager -import com.infomaniak.swisstransfer.BuildConfig +import com.infomaniak.multiplatform_swisstransfer.managers.UploadV2Manager +import com.infomaniak.swisstransfer.upload.UploadSessionStarter +import com.infomaniak.swisstransfer.upload.UploadSessionStarterV1 +import com.infomaniak.swisstransfer.upload.UploadSessionStarterV2 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment as KmpApiEnvironement @Module @InstallIn(SingletonComponent::class) @@ -40,12 +45,29 @@ object SwissTransferInjectionModule { @Singleton fun provideSwissTransferInjection(@UserAgent userAgent: String): SwissTransferInjection { return SwissTransferInjection( - environment = if (BuildConfig.FLAVOR == "preprod") ApiEnvironment.Preprod else ApiEnvironment.Prod, + environment = ApiEnvironment.current.toKmpApiEnvironment(), userAgent = userAgent, - crashReport = crashReport + crashReport = crashReport, + databaseNameOrPath = "swisstransfer", ) } + @Provides + fun provideUploadSessionStarter( + sti: SwissTransferInjection, + appIntegrityManager: AppIntegrityManager, + sharedApiUrlCreator: SharedApiUrlCreator, + legacyUploadManager: InMemoryUploadManager, + uploadManager: UploadV2Manager, + ): UploadSessionStarter = when { + sti.accountManager.shouldUseV1Api -> UploadSessionStarterV1( + appIntegrityManager = appIntegrityManager, + sharedApiUrlCreator = sharedApiUrlCreator, + uploadManager = legacyUploadManager, + ) + else -> UploadSessionStarterV2(uploadManager) + } + @Provides @Singleton fun provideTransferManager(sti: SwissTransferInjection): TransferManager = sti.transferManager @@ -64,9 +86,21 @@ object SwissTransferInjectionModule { @Provides @Singleton - fun provideUploadManager(sti: SwissTransferInjection): InMemoryUploadManager = sti.inMemoryUploadManager + fun provideV1UploadManager(sti: SwissTransferInjection): InMemoryUploadManager = sti.inMemoryUploadManager + + @Provides + @Singleton + fun provideUploadManager(sti: SwissTransferInjection): UploadV2Manager = sti.uploadV2Manager @Provides @Singleton fun provideSharedApiUrlCreator(sti: SwissTransferInjection): SharedApiUrlCreator = sti.sharedApiUrlCreator } + +private fun ApiEnvironment.toKmpApiEnvironment(): KmpApiEnvironement { + return when (this) { + ApiEnvironment.PreProd -> KmpApiEnvironement.Preprod + ApiEnvironment.Prod -> KmpApiEnvironement.Prod + is ApiEnvironment.Custom -> error("Custom api environment is not handled by the kmp lib") + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/LaunchActivity.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/LaunchActivity.kt index 54e1b7984..3a7e091bf 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/LaunchActivity.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/LaunchActivity.kt @@ -43,9 +43,6 @@ class LaunchActivity : ComponentActivity() { @Inject lateinit var accountUtils: AccountUtils - @Inject - lateinit var accountPreferences: AccountPreferences - @Inject lateinit var appSettingsManager: AppSettingsManager @@ -103,7 +100,7 @@ class LaunchActivity : ComponentActivity() { private suspend fun connectLoggedOutUser() { if (!accountUtils.isUserConnected()) { accountUtils.loginGuestUser() - accountPreferences.isOnboardingDone = true + AccountPreferences().isOnboardingDone = true } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/MainApplication.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/MainApplication.kt index b4d9211cb..fb63260ce 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/MainApplication.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/MainApplication.kt @@ -22,6 +22,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.infomaniak.core.common.AssociatedUserDataCleanable import com.infomaniak.core.crossapplogin.back.internal.deviceinfo.DeviceInfoUpdateManager +import com.infomaniak.core.network.ApiEnvironment import com.infomaniak.core.network.NetworkConfiguration import com.infomaniak.core.sentry.SentryConfig.configureSentry import com.infomaniak.multiplatform_swisstransfer.managers.AccountManager @@ -45,6 +46,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import splitties.init.injectAsAppCtx import javax.inject.Inject import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException as KmpNetworkException @@ -79,6 +81,11 @@ class MainApplication : Application(), Configuration.Provider { @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher + init { + injectAsAppCtx() + configureInfomaniakCore() + } + override val workManagerConfiguration: Configuration get() = Configuration.Builder().setWorkerFactory(workerFactory).build() @@ -87,7 +94,6 @@ class MainApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() - configureInfomaniakCore() configureSentry() userDataCleanableList = listOf(DeviceInfoUpdateManager) @@ -95,7 +101,7 @@ class MainApplication : Application(), Configuration.Provider { notificationUtils.initNotificationsChannel() globalCoroutineScope.launch { - accountUtils.init() + launch { accountUtils.activate() } launch(ioDispatcher) { transferManager.getAllTransfers().collectLatest(thumbnailsLocalStorage::cleanExpiredThumbnails) @@ -122,6 +128,7 @@ class MainApplication : Application(), Configuration.Provider { appId = ConfigUtils.safePackage, appVersionName = BuildConfig.VERSION_NAME, appVersionCode = BuildConfig.VERSION_CODE, + apiEnvironment = if (BuildConfig.FLAVOR == "preprod") ApiEnvironment.PreProd else ApiEnvironment.Prod ) } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/OnboardingActivity.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/OnboardingActivity.kt index a27d75d3e..1ed26d44e 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/OnboardingActivity.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/OnboardingActivity.kt @@ -69,9 +69,6 @@ class OnboardingActivity : ComponentActivity() { private val shouldDisplayRequiredLogin by lazy { intent.getBooleanExtra(EXTRA_REQUIRED_LOGIN_KEY, false) } - @Inject - lateinit var accountPreferences: AccountPreferences - @Inject lateinit var accountUtils: AccountUtils @@ -206,8 +203,8 @@ class OnboardingActivity : ComponentActivity() { areButtonsLoading = false } - private fun Activity.completeOnboarding() { - accountPreferences.isOnboardingDone = true + private suspend fun Activity.completeOnboarding() { + AccountPreferences().isOnboardingDone = true Intent(this, MainActivity::class.java).also(::startActivity) finish() diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/transfer/TransferItem.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/transfer/TransferItem.kt index 7c815acef..c7afbf606 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/transfer/TransferItem.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/transfer/TransferItem.kt @@ -93,7 +93,7 @@ fun TransferItem( Column(modifier = Modifier.weight(1.0f)) { Text( - text = createdDate, + text = transfer.title ?: createdDate, style = SwissTransferTheme.typography.bodyMedium, color = SwissTransferTheme.colors.primaryTextColor, maxLines = 1, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/previewparameter/GroupedTransfersPreviewParameterProvider.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/previewparameter/GroupedTransfersPreviewParameterProvider.kt index 16627d087..b78c834f3 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/previewparameter/GroupedTransfersPreviewParameterProvider.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/previewparameter/GroupedTransfersPreviewParameterProvider.kt @@ -37,46 +37,58 @@ private val now get() = Clock.System.now().epochSeconds val transfersPreviewData = listOf( TransferUi( uuid = UUID.randomUUID().toString(), + linkId = UUID.randomUUID().toString(), createdDateTimestamp = now - (0.5f * SECONDS_IN_A_DAY).toLong(), expirationDateTimestamp = now + 3 * SECONDS_IN_A_DAY, sizeUploaded = 237_866_728L, downloadLimit = 250, downloadLeft = 123, + title = "Transfer title", message = "3ème transfert. RAS.", password = "my password", files = filesPreviewData, + apiSource = TransferUi.ApiSource.V1, ), TransferUi( uuid = UUID.randomUUID().toString(), + linkId = UUID.randomUUID().toString(), createdDateTimestamp = now - (0.6f * SECONDS_IN_A_DAY).toLong(), expirationDateTimestamp = now + 3 * SECONDS_IN_A_DAY, sizeUploaded = 237_866_728L, downloadLimit = 250, downloadLeft = 123, + title = null, message = null, password = "my password", files = filesPreviewData, + apiSource = TransferUi.ApiSource.V1, ), TransferUi( uuid = UUID.randomUUID().toString(), + linkId = UUID.randomUUID().toString(), createdDateTimestamp = now - 5L * SECONDS_IN_A_DAY, expirationDateTimestamp = now + 5L * SECONDS_IN_A_DAY, sizeUploaded = 89_723_143L, downloadLimit = 20, downloadLeft = 0, + title = null, message = null, password = null, files = filesPreviewData, + apiSource = TransferUi.ApiSource.V2, ), TransferUi( uuid = UUID.randomUUID().toString(), + linkId = UUID.randomUUID().toString(), createdDateTimestamp = now - 30L * SECONDS_IN_A_DAY, expirationDateTimestamp = now - 4L * SECONDS_IN_A_DAY, sizeUploaded = 57_689_032L, downloadLimit = 1, downloadLeft = 1, + title = null, message = "Coucou c'est moi le message de description du transfert.", password = "password", files = filesPreviewData, + apiSource = TransferUi.ApiSource.V2, ), ) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsScreen.kt index 94df4c314..320e40c54 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsScreen.kt @@ -184,7 +184,7 @@ private fun Theme?.getString(): String { @Composable private fun ValidityPeriod?.getString(): String { - return this?.value?.toInt()?.let { + return this?.days?.let { pluralStringResource(R.plurals.settingsValidityPeriodValue, it, it) } ?: "" } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsValidityPeriodScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsValidityPeriodScreen.kt index 294d7e0ea..4e304927d 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsValidityPeriodScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/settings/SettingsValidityPeriodScreen.kt @@ -77,7 +77,7 @@ enum class ValidityPeriodOption( @Composable private fun getValidityPeriodTitle(validityPeriod: ValidityPeriod): String { - val count = validityPeriod.value.toInt() + val count = validityPeriod.days return pluralStringResource(R.plurals.settingsValidityPeriodValue, count, count) } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsScreen.kt index 8233f1172..79ad8af95 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsScreen.kt @@ -71,10 +71,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.rememberPermissionState -import com.infomaniak.core.ui.compose.margin.Margin -import com.infomaniak.core.ui.compose.preview.PreviewAllWindows import com.infomaniak.core.common.utils.FORMAT_DATE_FULL import com.infomaniak.core.common.utils.format +import com.infomaniak.core.ui.compose.margin.Margin +import com.infomaniak.core.ui.compose.preview.PreviewAllWindows import com.infomaniak.multiplatform_swisstransfer.common.ext.toDateFromSeconds import com.infomaniak.multiplatform_swisstransfer.common.interfaces.ui.FileUi import com.infomaniak.multiplatform_swisstransfer.common.interfaces.ui.TransferUi @@ -106,6 +106,7 @@ import com.infomaniak.swisstransfer.ui.screen.main.transferdetails.emptystate.Em import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.DeeplinkPasswordAlertDialog import com.infomaniak.swisstransfer.ui.theme.LocalWindowAdaptiveInfo import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme +import com.infomaniak.swisstransfer.ui.utils.isV1 import com.infomaniak.swisstransfer.ui.utils.isWindowSmall import com.infomaniak.swisstransfer.ui.utils.openFile import com.infomaniak.swisstransfer.ui.utils.shareText @@ -244,14 +245,18 @@ private fun TransferDetailsScreen( } } + val title = getTransfer().title ?: getTransfer().createdDateTimestamp.toDateFromSeconds().format(FORMAT_DATE_FULL) + SwissTransferScaffold( topBar = { SwissTransferTopAppBar( - title = getTransfer().createdDateTimestamp.toDateFromSeconds().format(FORMAT_DATE_FULL), + title = title, navigationIcon = { if (windowAdaptiveInfo.isWindowSmall()) TopAppBarButtons.Back(onClick = navigateBack ?: {}) }, actions = { when (direction) { - TransferDirection.SENT -> downloadUi.TopAppBarButton() + TransferDirection.SENT -> { + if (getTransfer().isV1()) downloadUi.TopAppBarButton() + } TransferDirection.RECEIVED -> TopAppBarButtons.QrCode { MatomoSwissTransfer.trackReceivedTransferEvent(MatomoName.ShowQRCode) showQrCodeBottomSheet = true diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsViewModel.kt index 81ea73fc5..0cc1d12c2 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsViewModel.kt @@ -201,6 +201,7 @@ class TransferDetailsViewModel @Inject constructor( private fun TransferUi?.toUiState(isInLocal: Boolean): TransferDetailsUiState = when (this?.transferStatus) { TransferStatus.NOT_YET_FETCHED -> Loading + TransferStatus.PENDING_UPLOAD -> Loading TransferStatus.UNKNOWN -> Unknown TransferStatus.READY -> Success(this) TransferStatus.EXPIRED_DOWNLOAD_QUOTA -> ByQuota(downloadLimit, isInLocal) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDownload.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDownload.kt index d5727dd88..a63a0c6ee 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDownload.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDownload.kt @@ -96,7 +96,7 @@ suspend fun handleTransferDownload( } downloadManager.cancelAndRemove(id) transferManager.writeDownloadManagerId( - transferUUID = transfer.uuid, + transfer = transfer, fileUid = targetFile?.uid, uniqueDownloadManagerId = null, ) @@ -104,10 +104,10 @@ suspend fun handleTransferDownload( fun downloadManagerId( transferManager: TransferManager, - transferUuid: String, + transfer: TransferUi, fileUid: String?, ): Flow = transferManager.downloadManagerIdFor( - transferUUID = transferUuid, + transfer = transfer, fileUid = fileUid, ).map { UniqueDownloadId(it ?: return@map null) } @@ -139,7 +139,7 @@ private fun currentOrNewDownloadManagerId( direction: TransferDirection?, ): Flow = downloadManagerId( transferManager = transferManager, - transferUuid = transfer.uuid, + transfer = transfer, fileUid = targetFile?.uid, ).mapLatest { id -> id ?: repeatWhileActive { @@ -203,7 +203,7 @@ private suspend fun getNewDownloadId( val request = buildDownloadRequest(transfer, targetFile, apiUrlCreator, userAgent, direction) ?: return null val newId = downloadManager.startDownloadingFile(request) transferManager.writeDownloadManagerId( - transferUUID = transfer.uuid, + transfer = transfer, fileUid = targetFile?.uid, uniqueDownloadManagerId = newId?.value, ) @@ -238,15 +238,23 @@ private suspend fun buildDownloadRequest( when { targetFile != null -> { - url = apiUrlCreator.downloadFileUrl(transfer.uuid, targetFile.uid) ?: return null + url = apiUrlCreator.downloadFileUrl(transfer, targetFile.uid) ?: return null val fileName = DownloadManagerUtils.withoutProblematicCharacters(targetFile.fileName) name = "SwissTransfer/$fileName${if (targetFile.isFolder) ".zip" else ""}" } - else -> { + transfer.apiSource == TransferUi.ApiSource.V1 -> { url = apiUrlCreator.downloadFilesUrl(transfer.uuid) ?: return null val fileName = currentDateTimeWithSecondsString() name = "SwissTransfer/$fileName.zip" } + else -> return null + } + + val extraHeaders = buildSet { + if (transfer.apiSource == TransferUi.ApiSource.V2) { + val password = transfer.password ?: return@buildSet + add("Transfer-Password" to password) + } } return runCatching { @@ -255,6 +263,7 @@ private suspend fun buildDownloadRequest( nameWithoutProblematicChars = name, mimeType = Dispatchers.IO { FileType.guessMimeTypeFromFileName(name) }, userAgent = userAgent, + extraHeaders = extraHeaders, ) }.cancellable().onFailure { // Unlikely to happen since mitigation in requestFor, but we don't want to crash the app if it happens. diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferManagerExt.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferManagerExt.kt index 3dcfa9176..ad86f8319 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferManagerExt.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferManagerExt.kt @@ -41,7 +41,7 @@ fun TransferManager.previewUriForFile( thumbnailsLocalStorage: ThumbnailsLocalStorage, ): Flow = downloadManagerId( transferManager = this, - transferUuid = transfer.uuid, + transfer = transfer, fileUid = file.uid, ).transformLatest { uniqueDownloadId -> if (file.thumbnailPath != null) { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/components/TransferInfo.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/components/TransferInfo.kt index 22552addc..ac8ec0581 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/components/TransferInfo.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/components/TransferInfo.kt @@ -53,6 +53,7 @@ import com.infomaniak.swisstransfer.ui.theme.Dimens import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme import com.infomaniak.swisstransfer.ui.utils.HumanReadableSizeUtils import com.infomaniak.swisstransfer.ui.utils.getWholeDate +import com.infomaniak.swisstransfer.ui.utils.isV1 @Composable fun TransferInfo(getTransfer: () -> TransferUi) { @@ -84,7 +85,7 @@ fun TransferInfo(getTransfer: () -> TransferUi) { text = getTransfer().getWholeDate(), ) - if (direction == TransferDirection.SENT) { + if (direction == TransferDirection.SENT && getTransfer().isV1()) { HorizontalDivider(modifier = Modifier.padding(vertical = Margin.Medium)) IconText( diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 8250e99a1..92f767639 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -25,8 +25,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -37,7 +37,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -52,24 +54,28 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.rememberPermissionState +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.common.mapSync import com.infomaniak.core.ui.compose.bottomstickybuttonscaffolds.BottomStickyButtonScaffold import com.infomaniak.core.ui.compose.margin.Margin import com.infomaniak.core.ui.compose.preview.PreviewAllWindows -import com.infomaniak.core.common.mapSync +import com.infomaniak.core.ui.compose.preview.previewparameter.UserListPreviewParameterProvider import com.infomaniak.multiplatform_swisstransfer.common.interfaces.ui.FileUi import com.infomaniak.multiplatform_swisstransfer.common.matomo.MatomoScreen import com.infomaniak.swisstransfer.R +import com.infomaniak.swisstransfer.ui.LocalUser import com.infomaniak.swisstransfer.ui.MatomoSwissTransfer import com.infomaniak.swisstransfer.ui.components.ButtonType import com.infomaniak.swisstransfer.ui.components.LargeButton import com.infomaniak.swisstransfer.ui.components.SwissTransferTextField import com.infomaniak.swisstransfer.ui.components.SwissTransferTopAppBar import com.infomaniak.swisstransfer.ui.components.TopAppBarButtons -import com.infomaniak.swisstransfer.ui.previewparameter.FileUiListPreviewParameter +import com.infomaniak.swisstransfer.ui.previewparameter.filesPreviewData import com.infomaniak.swisstransfer.ui.screen.main.settings.DownloadLimitOption import com.infomaniak.swisstransfer.ui.screen.main.settings.EmailLanguageOption import com.infomaniak.swisstransfer.ui.screen.main.settings.ValidityPeriodOption @@ -84,6 +90,7 @@ import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.T import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferTypeUi import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme import com.infomaniak.swisstransfer.ui.utils.GetSetCallbacks +import com.infomaniak.swisstransfer.ui.utils.isApiV2 import com.infomaniak.swisstransfer.upload.UploadForegroundService import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.consumeEach @@ -178,6 +185,7 @@ fun PickFilesScreen( PickFilesScreen( files = { files }, canSendStatus = { canSendStatus }, + transferTitleState = pickFilesViewModel.transferTitleState, emailTextFieldCallbacks = emailTextFieldCallbacks, transferMessageCallbacks = pickFilesViewModel.transferMessageCallbacks, selectedTransferType = GetSetCallbacks( @@ -213,6 +221,7 @@ private fun HandleStartupFilePick(openFilePickerEvent: ReceiveChannel, pic private fun PickFilesScreen( files: () -> List, canSendStatus: () -> CanSendStatus, + transferTitleState: MutableState, emailTextFieldCallbacks: EmailTextFieldCallbacks, transferMessageCallbacks: GetSetCallbacks, selectedTransferType: GetSetCallbacks, @@ -251,6 +260,7 @@ private fun PickFilesScreen( Spacer(Modifier.height(Margin.Medium)) ImportTextFields( horizontalPaddingModifier = modifier, + transferTitleState = transferTitleState, emailTextFieldCallbacks = emailTextFieldCallbacks, transferMessageCallbacks = transferMessageCallbacks, shouldShowEmailAddressesFields = { shouldShowEmailAddressesFields }, @@ -281,49 +291,67 @@ private fun FilesToImport( } @Composable -private fun ColumnScope.ImportTextFields( +private fun ImportTextFields( horizontalPaddingModifier: Modifier, + transferTitleState: MutableState, emailTextFieldCallbacks: EmailTextFieldCallbacks, transferMessageCallbacks: GetSetCallbacks, shouldShowEmailAddressesFields: () -> Boolean, ) { val modifier = horizontalPaddingModifier.fillMaxWidth() - EmailAddressesTextFields(modifier, emailTextFieldCallbacks, shouldShowEmailAddressesFields) - SwissTransferTextField( - modifier = modifier, - label = stringResource(R.string.transferMessagePlaceholder), - isRequired = false, - minLineNumber = 3, - capitalization = KeyboardCapitalization.Sentences, - onValueChange = transferMessageCallbacks.set, - ) + + val textFieldSpacing = Margin.Medium + Column(verticalArrangement = Arrangement.spacedBy(textFieldSpacing)) { + if (LocalUser.current.isApiV2()) { + SwissTransferTextField( + modifier = modifier, + label = stringResource(R.string.transferTitlePlaceholder), + isRequired = false, + maxLineNumber = 1, + onValueChange = { transferTitleState.value = it }, + ) + } + + EmailAddressesTextFields(modifier, emailTextFieldCallbacks, shouldShowEmailAddressesFields, textFieldSpacing) + + SwissTransferTextField( + modifier = modifier, + label = stringResource(R.string.transferMessagePlaceholder), + isRequired = false, + minLineNumber = 3, + capitalization = KeyboardCapitalization.Sentences, + onValueChange = transferMessageCallbacks.set, + ) + } } @Composable -private fun ColumnScope.EmailAddressesTextFields( +private fun EmailAddressesTextFields( modifier: Modifier, emailTextFieldCallbacks: EmailTextFieldCallbacks, shouldShowEmailAddressesFields: () -> Boolean, + textFieldSpacing: Dp, ) = with(emailTextFieldCallbacks) { - AnimatedVisibility(visible = shouldShowEmailAddressesFields()) { - Column { + AnimatedVisibility(visible = shouldShowEmailAddressesFields(), modifier = modifier) { + Column(verticalArrangement = Arrangement.spacedBy(textFieldSpacing)) { val isAuthorError = checkEmailError(isAuthor = true) val isRecipientError = checkEmailError(isAuthor = false) SwissTransferTextField( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), label = stringResource(R.string.transferSenderAddressPlaceholder), initialValue = transferAuthorEmail.get(), keyboardType = KeyboardType.Email, maxLineNumber = 1, imeAction = ImeAction.Next, isError = isAuthorError, + isReadOnly = LocalUser.current.isApiV2(), supportingText = getEmailError(isAuthorError), onValueChange = transferAuthorEmail.set, ) - Spacer(Modifier.height(Margin.Medium)) + EmailAddressTextField( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), label = stringResource(R.string.transferRecipientAddressPlaceholder), initialValue = recipientEmail.get(), validatedRecipientsEmails = validatedRecipientsEmails, @@ -331,7 +359,6 @@ private fun ColumnScope.EmailAddressesTextFields( isError = isRecipientError, supportingText = getEmailError(isRecipientError), ) - Spacer(Modifier.height(Margin.Medium)) } } } @@ -472,7 +499,8 @@ enum class PasswordTransferOption( @PreviewAllWindows @Composable -private fun Preview(@PreviewParameter(FileUiListPreviewParameter::class) files: List) { +private fun Preview(@PreviewParameter(UserListPreviewParameterProvider::class) users: List) { + val files = filesPreviewData val transferOptionsCallbacks = TransferOptionsCallbacks( transferOptionsStates = { listOf( @@ -507,19 +535,22 @@ private fun Preview(@PreviewParameter(FileUiListPreviewParameter::class) files: validatedRecipientsEmails = GetSetCallbacks(get = { setOf("test.test@ik.me") }, set = {}), ) - SwissTransferTheme { - PickFilesScreen( - files = { files }, - canSendStatus = { CanSendStatus.Yes }, - emailTextFieldCallbacks = emailTextFieldCallbacks, - transferMessageCallbacks = GetSetCallbacks(get = { "" }, set = {}), - selectedTransferType = GetSetCallbacks(get = { TransferTypeUi.Mail }, set = {}), - transferOptionsCallbacks = transferOptionsCallbacks, - pickFiles = {}, - exitNewTransfer = {}, - onSendButtonClick = {}, - isAwaitingSend = { true }, - navigateToFilesDetails = {}, - ) + CompositionLocalProvider(LocalUser provides users.first()) { + SwissTransferTheme { + PickFilesScreen( + files = { files }, + canSendStatus = { CanSendStatus.Yes }, + transferTitleState = remember { mutableStateOf("") }, + emailTextFieldCallbacks = emailTextFieldCallbacks, + transferMessageCallbacks = GetSetCallbacks(get = { "" }, set = {}), + selectedTransferType = GetSetCallbacks(get = { TransferTypeUi.Mail }, set = {}), + transferOptionsCallbacks = transferOptionsCallbacks, + pickFiles = {}, + exitNewTransfer = {}, + onSendButtonClick = {}, + isAwaitingSend = { true }, + navigateToFilesDetails = {}, + ) + } } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index dd63b2aac..1256171f3 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -30,12 +30,12 @@ import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.infomaniak.core.ui.compose.basics.CallableState -import com.infomaniak.core.ui.compose.basics.collectAsStateIn import com.infomaniak.core.common.mapSync -import com.infomaniak.core.sentry.SentryLog import com.infomaniak.core.common.tryCompletingWhileTrue import com.infomaniak.core.common.utils.isEmailRfc5321Compliant +import com.infomaniak.core.sentry.SentryLog +import com.infomaniak.core.ui.compose.basics.CallableState +import com.infomaniak.core.ui.compose.basics.collectAsStateIn import com.infomaniak.multiplatform_swisstransfer.common.interfaces.ui.FileUi import com.infomaniak.multiplatform_swisstransfer.managers.AppSettingsManager import com.infomaniak.multiplatform_swisstransfer.utils.FileUtils @@ -54,6 +54,7 @@ import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.PickFilesVie import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.PickFilesViewModel.CanSendStatus.Issue import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferTypeUi import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferTypeUi.Companion.toTransferTypeUi +import com.infomaniak.swisstransfer.ui.utils.AccountUtils import com.infomaniak.swisstransfer.ui.utils.GetSetCallbacks import com.infomaniak.swisstransfer.upload.NewTransferParams import com.infomaniak.swisstransfer.upload.UploadForegroundService @@ -66,8 +67,10 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import splitties.coroutines.repeatWhileActive @@ -76,6 +79,7 @@ import javax.inject.Inject @HiltViewModel class PickFilesViewModel @Inject constructor( + private val accountUtils: AccountUtils, private val appSettingsManager: AppSettingsManager, private val newTransferOpenManager: NewTransferOpenManager, private val savedStateHandle: SavedStateHandle, @@ -147,6 +151,11 @@ class PickFilesViewModel @Inject constructor( private var validatedRecipientsEmails by mutableStateOf>(emptySet()) //endregion + //region Transfer title + val transferTitleState = mutableStateOf("") + private var transferTitle by transferTitleState + //endregion + //region Transfer Message private var transferMessage by mutableStateOf("") val transferMessageCallbacks = GetSetCallbacks(get = { transferMessage }, set = { transferMessage = it }) @@ -188,6 +197,11 @@ class PickFilesViewModel @Inject constructor( initialValue = emptyList(), ) + viewModelScope.launch { + accountUtils.currentUserFlow.mapNotNull { it?.email }.distinctUntilChanged().collect { userEmail -> + transferAuthorEmail = userEmail + } + } viewModelScope.launch { handleSessionStart() } viewModelScope.launch(ioDispatcher) { if (isFirstViewModelCreation) { @@ -288,6 +302,7 @@ class PickFilesViewModel @Inject constructor( validityPeriod = selectedValidityPeriodOption.value.apiValue, authorEmail = if (selectedTransferTypeFlow.value == TransferTypeUi.Mail) transferAuthorEmail.trim() else "", password = if (selectedPasswordOption.value == PasswordTransferOption.ACTIVATED) transferPassword else NO_PASSWORD, + title = transferTitle, message = transferMessage, downloadCountLimit = selectedDownloadLimitOption.value.apiValue, languageCode = selectedLanguageOption.value.apiValue, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt index 0f18261d6..29af3f739 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -57,6 +58,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -68,10 +70,12 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.infomaniak.core.common.utils.isEmailRfc5321Compliant import com.infomaniak.core.ui.compose.margin.Margin import com.infomaniak.core.ui.compose.preview.PreviewLightAndDark -import com.infomaniak.core.common.utils.isEmailRfc5321Compliant import com.infomaniak.swisstransfer.R import com.infomaniak.swisstransfer.ui.components.SwissTransferInputChip import com.infomaniak.swisstransfer.ui.components.SwissTransferTextFieldDefaults @@ -130,7 +134,7 @@ fun EmailAddressTextField( } BasicTextField( - modifier = emailAddressTextFieldModifier, + modifier = emailAddressTextFieldModifier.padding(top = minimizedLabelHalfHeight()), value = textFieldValue, onValueChange = state::updateUiTextValue, textStyle = TextStyle(color = SwissTransferTheme.colors.primaryTextColor), @@ -155,6 +159,17 @@ fun EmailAddressTextField( ) } +/** + * Copied from OutlineTextField so the BasicTextField can have the same spacing as OutlineTextField with a label + */ +@Composable +private fun minimizedLabelHalfHeight(): Dp { + val compositionLocalValue = MaterialTheme.typography.bodySmall.lineHeight + val fallbackValue = 16.sp + val value = if (compositionLocalValue.isSp) compositionLocalValue else fallbackValue + return with(LocalDensity.current) { value.toDp() / 2 } +} + private class EmailAddressTextFieldState( initialText: String, private val validatedRecipientsEmails: GetSetCallbacks>, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountPreferences.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountPreferences.kt index aac24e661..30941622a 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountPreferences.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountPreferences.kt @@ -15,35 +15,42 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalSplittiesApi::class) + package com.infomaniak.swisstransfer.ui.utils -import android.content.Context -import com.infomaniak.core.sharedvalues.SharedValues -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton +import androidx.core.content.edit +import kotlinx.coroutines.flow.Flow +import splitties.experimental.ExperimentalSplittiesApi +import splitties.preferences.Preferences +import splitties.preferences.SuspendPrefsAccessor + +class AccountPreferences private constructor(): Preferences(name = "AccountPreferences") { + companion object : SuspendPrefsAccessor(::AccountPreferences) { + private const val GUEST_USER_ID = 0 + private const val NO_USER = -1 + } -@Singleton -class AccountPreferences @Inject constructor(@ApplicationContext private val appContext: Context) : SharedValues { + val isOnboardingDoneFlow: Flow + var isOnboardingDone by boolPref(key = "isOnboardingDone", defaultValue = false).also { + isOnboardingDoneFlow = it.valueFlow() + } - override val sharedPreferences = appContext.applicationContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)!! + init { + migrateOldDataIfNeeded() + } - /** - * This used to be currentUserId but in the end, the data of the current user is stored inside of - * [com.infomaniak.core.auth.PersistedCurrentUserAccountUtils]. The [_currentGuestUserId] will always contain the guest user - * id if the onboarding is done, even if another real [com.infomaniak.core.auth.models.user.User] account is connected and - * currently selected. No other user id value has ever been stored here. - */ - private var _currentGuestUserId by sharedValue("currentUserId", NO_USER) - var isOnboardingDone - get() = _currentGuestUserId != NO_USER - set(value) { - _currentGuestUserId = if (value) GUEST_USER_ID else NO_USER + private fun migrateOldDataIfNeeded() { + /** + * This used to be currentUserId but in the end, the data of the current user is stored inside of + * [com.infomaniak.core.auth.PersistedCurrentUserAccountUtils]. + * The value behind [legacyCurrentGuestUserIdKey] will always contain the guest user + * id if the onboarding is done. No other user id value has ever been stored here. + */ + val legacyCurrentGuestUserIdKey = "currentUserId" + if (legacyCurrentGuestUserIdKey in prefs) { + isOnboardingDone = prefs.getInt(legacyCurrentGuestUserIdKey, NO_USER) == GUEST_USER_ID + prefs.edit(commit = true) { remove(legacyCurrentGuestUserIdKey) } } - - companion object { - private const val SHARED_PREFS_NAME = "AccountPreferences" - private const val NO_USER = -1 - const val GUEST_USER_ID = 0 } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountUtils.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountUtils.kt index 6c15f0218..373049ec4 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountUtils.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/AccountUtils.kt @@ -18,14 +18,15 @@ package com.infomaniak.swisstransfer.ui.utils import android.content.Context -import android.database.sqlite.SQLiteConstraintException import com.infomaniak.core.auth.PersistedCurrentUserAccountUtils import com.infomaniak.core.auth.models.user.User +import com.infomaniak.multiplatform_swisstransfer.data.STUser import com.infomaniak.multiplatform_swisstransfer.managers.AccountManager import com.infomaniak.swisstransfer.ui.MainApplication -import com.infomaniak.swisstransfer.ui.utils.AccountPreferences.Companion.GUEST_USER_ID import dagger.hilt.android.qualifiers.ApplicationContext import io.sentry.Sentry +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -34,45 +35,42 @@ import io.sentry.protocol.User as SentryUser @Singleton class AccountUtils @Inject constructor( private val accountManager: AccountManager, - private val accountPreferences: AccountPreferences, @ApplicationContext context: Context, ) : PersistedCurrentUserAccountUtils(context, MainApplication.userDataCleanableList) { - suspend fun init() { - val realUserId = currentUserIdFlow.first() - val userId = realUserId ?: GUEST_USER_ID.takeIf { accountPreferences.isOnboardingDone } - if (userId == null) { - Sentry.setUser(SentryUser().apply { id = "-1" }) - } else { - accountManager.loadUser(userId) - } + suspend fun activate(): Nothing { - currentUserFlow.collect { user -> + combine(currentUserFlow, AccountPreferences().isOnboardingDoneFlow) { user, isOnboardingDone -> + user to isOnboardingDone + }.collect { (currentUser, isOnboardingDone) -> Sentry.setUser(SentryUser().apply { - id = user?.id?.toString() ?: "-1" - email = user?.email + id = currentUser?.id?.toString() ?: "-1" + email = currentUser?.email }) + + val stUser: STUser = currentUser.toStUser().takeIf { isOnboardingDone } ?: return@collect + + accountManager.loadUser(stUser) } + awaitCancellation() // Unreachable because currentUserFlow is infinite. } suspend fun loginGuestUser() { - accountManager.loadUser(GUEST_USER_ID) - } - - /** - * @throws SQLiteConstraintException when adding a user with a primary key that already exists - */ - override suspend fun addUser(user: User) { - super.addUser(user) - accountManager.loadUser(user.id) + accountManager.loadUser(STUser.GuestUser) } override suspend fun removeUser(userId: Int) { super.removeUser(userId) - accountManager.removeUser(userId) + accountManager.logoutCurrentUser(newSTUser = userDao.getFirst().toStUser()) + if (currentUserIdFlow.first() == null) loginGuestUser() } - fun isUserConnected(): Boolean = accountPreferences.isOnboardingDone + private fun User?.toStUser(): STUser = when (this) { + null -> STUser.GuestUser + else -> STUser.AuthUser(id = id.toLong(), token = apiToken.accessToken) + } + + suspend fun isUserConnected(): Boolean = AccountPreferences().isOnboardingDone } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/TransferUiExt.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/TransferUiExt.kt index ccb964f81..c67e0d0a8 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/TransferUiExt.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/TransferUiExt.kt @@ -101,3 +101,7 @@ fun TransferUi.getWholeDate(): String { expiry.format(FORMAT_HOUR_MINUTES), ) } + +fun TransferUi.isV1(): Boolean = apiSource == TransferUi.ApiSource.V1 + +fun TransferUi.isV2(): Boolean = apiSource == TransferUi.ApiSource.V2 diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/UserExt.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/UserExt.kt new file mode 100644 index 000000000..f866f666c --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/UserExt.kt @@ -0,0 +1,22 @@ +/* + * Infomaniak SwissTransfer - 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.swisstransfer.ui.utils + +import com.infomaniak.core.auth.models.user.User + +fun User?.isApiV2() = this != null diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/AbandonedTransferV2CleanupWorker.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/AbandonedTransferV2CleanupWorker.kt new file mode 100644 index 000000000..f4a68f253 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/AbandonedTransferV2CleanupWorker.kt @@ -0,0 +1,119 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2025-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 . + */ +@file:Suppress("RemoveRedundantQualifierName") // TODO: Report the issue in YouTrack + +package com.infomaniak.swisstransfer.upload + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import com.infomaniak.core.appintegrity.exceptions.NetworkException +import com.infomaniak.core.common.cancellable +import com.infomaniak.core.sentry.SentryLog +import com.infomaniak.multiplatform_swisstransfer.managers.AccountManager +import com.infomaniak.multiplatform_swisstransfer.managers.UploadV2Manager +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.hours + +@HiltWorker +class AbandonedTransferV2CleanupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val uploadManager: UploadV2Manager, + private val accountManager: AccountManager, +) : CoroutineWorker(appContext, params) { + + private object DataKeys { + const val TRANSFER_ID = "transfer_id" + const val USER_ID = "user_id" + const val FAILED = "failed" + const val REQUEST_UTC_TIMESTAMP_MILLIS = "request_utc_timestamp_millis" + } + + private fun isBeyondBackedAutoCleanup(): Boolean { //TODO[ST-v2]: Duplicates the one in AbandonedTransferCleanupWorker. Fix it + val requestUtcTimestampMillis = inputData.getLong(DataKeys.REQUEST_UTC_TIMESTAMP_MILLIS, 0L) + val giveUpUtcTimestampMillis = requestUtcTimestampMillis + backendAutoCleanupDelay.inWholeMilliseconds + return System.currentTimeMillis() >= giveUpUtcTimestampMillis + } + + override suspend fun doWork(): Result { + if (isBeyondBackedAutoCleanup()) return Result.failure() + if (accountManager.currentUser?.id != inputData.getLong(key = DataKeys.USER_ID, defaultValue = -1)) { + SentryLog.e(TAG, "Couldn't complete the abandoner transfer cleanup because the user id changed in the meantime.") + return Result.failure() + } + + val transferId = inputData.getString(DataKeys.TRANSFER_ID)!! + val failed = inputData.getBoolean(DataKeys.FAILED, defaultValue = false) + return runCatching { + uploadManager.cancelTransfer(transferId = transferId, failed = failed) + Result.success() + }.cancellable().getOrElse { t -> + when (t) { + is NetworkException -> Result.retry() + else -> { + SentryLog.e(TAG, "Failed to do the cleanup", t) + Result.retry() + } + } + } + } + + companion object { + + suspend fun schedule( + workManager: WorkManager, + userId: Long, + transferId: String, + failed: Boolean + ): kotlin.Result = runCatching { + val inputData = Data.Builder() + .putLong(DataKeys.USER_ID, userId) + .putString(DataKeys.TRANSFER_ID, transferId) + .putBoolean(DataKeys.FAILED, failed) + .putLong(DataKeys.REQUEST_UTC_TIMESTAMP_MILLIS, System.currentTimeMillis()) + .build() + + workManager.enqueueUniqueWork( + uniqueWorkName = transferId, + existingWorkPolicy = ExistingWorkPolicy.KEEP, + request = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(backoffPolicy = BackoffPolicy.LINEAR, 10L, TimeUnit.MINUTES) + .build() + ).await() + Unit // We don't need the fancy completion type Operation.State.SUCCESS from androidx.work + }.cancellable() + + private val backendAutoCleanupDelay = 24.hours + + private const val TAG = "AbandonedTransferV2CleanupWorker" + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParams.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParams.kt index 3c0f56e34..d44a7a36a 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParams.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParams.kt @@ -26,6 +26,7 @@ data class NewTransferParams( val validityPeriod: ValidityPeriod, val authorEmail: String, val password: String, + val title: String?, val message: String, val downloadCountLimit: DownloadLimit, val languageCode: EmailLanguage, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParamsExt.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParamsExt.kt index cdf189bfb..b354ccfb1 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParamsExt.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/NewTransferParamsExt.kt @@ -26,6 +26,7 @@ fun NewTransferParams.toUploadSessionRequest( validityPeriod = validityPeriod, authorEmail = authorEmail, password = password, + title = title, message = message, sizeOfUpload = filesMetadata.sumOf { it.size }, downloadCountLimit = downloadCountLimit, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploader.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploader.kt index 15822692e..fec7d6a83 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploader.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploader.kt @@ -33,8 +33,8 @@ import com.infomaniak.multiplatform_swisstransfer.managers.TransferManager import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException import com.infomaniak.swisstransfer.ui.screen.newtransfer.PickedFile import com.infomaniak.swisstransfer.ui.screen.newtransfer.ThumbnailsLocalStorage -import com.infomaniak.swisstransfer.upload.TransferUploader.ChunkUploadStatus.DefinitelyComplete -import com.infomaniak.swisstransfer.upload.TransferUploader.ChunkUploadStatus.StartedOrComplete +import com.infomaniak.swisstransfer.upload.TransferUploaderV1.ChunkUploadStatus.DefinitelyComplete +import com.infomaniak.swisstransfer.upload.TransferUploaderV1.ChunkUploadStatus.StartedOrComplete import com.infomaniak.swisstransfer.upload.UploadState.Ongoing.Uploading.Status import com.infomaniak.swisstransfer.workers.FileChunkSizeManager import io.ktor.http.content.OutgoingContent @@ -56,15 +56,15 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @OptIn(ExperimentalAtomicApi::class) -class TransferUploader( - val startRequest: StartUploadRequest, +class TransferUploaderV1( + override val startRequest: StartUploadRequest, private val uploadManager: InMemoryUploadManager, private val transferManager: TransferManager, private val fileChunkSizeManager: FileChunkSizeManager, private val request: UploadSessionRequest, private val destination: UploadDestination, private val thumbnailsLocalStorage: ThumbnailsLocalStorage, -) { +) : TransferUploader { private val pickedFiles: List = startRequest.files @@ -107,7 +107,7 @@ class TransferUploader( /** * Returns the transfer UUID once the upload completes successfully. */ - suspend fun uploadRemainderOrThrow(updateState: (UploadState.Ongoing) -> Unit): String = autoCancelScope { + override suspend fun uploadRemainderOrThrow(updateState: (UploadState.Ongoing) -> Unit) = autoCancelScope { launch { uploadedBytesUpdates.collect { _ -> updateState(newUploadState(uploadedBytes.load())) @@ -124,7 +124,7 @@ class TransferUploader( ) } - private suspend fun uploadRemainderOrThrow(): String { + private suspend fun uploadRemainderOrThrow(): TransferUploader.UploadResult { uploadFiles() val session = NewUploadSession( duration = request.validityPeriod, @@ -148,7 +148,7 @@ class TransferUploader( thumbnailRootPath = thumbnailsLocalStorage.getThumbnailsFolderFor(transferUuid).toString(), ) - return transferUuid + return TransferUploader.UploadResult(transferId = transferUuid, linkId = null) } private suspend fun uploadFiles() { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderInterface.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderInterface.kt new file mode 100644 index 000000000..dfbea06b9 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderInterface.kt @@ -0,0 +1,28 @@ +/* + * Infomaniak SwissTransfer - 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.swisstransfer.upload + +interface TransferUploader { + suspend fun uploadRemainderOrThrow(updateState: (UploadState.Ongoing) -> Unit): UploadResult + val startRequest: StartUploadRequest + + data class UploadResult( + val transferId: String, + val linkId: String? + ) +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderV2.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderV2.kt new file mode 100644 index 000000000..ea4108290 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/TransferUploaderV2.kt @@ -0,0 +1,284 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2025-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.swisstransfer.upload + +import android.content.ContentResolver +import android.net.Uri +import android.webkit.MimeTypeMap +import com.infomaniak.core.common.autoCancelScope +import com.infomaniak.core.sentry.SentryLog +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.Transfer +import com.infomaniak.multiplatform_swisstransfer.managers.TransferManager +import com.infomaniak.multiplatform_swisstransfer.managers.UploadV2Manager +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.ChunkEtag +import com.infomaniak.swisstransfer.ui.screen.newtransfer.PickedFile +import com.infomaniak.swisstransfer.ui.screen.newtransfer.ThumbnailsLocalStorage +import com.infomaniak.swisstransfer.upload.UploadState.Ongoing.Uploading.Status +import com.infomaniak.swisstransfer.workers.FileChunkSizeManager +import io.ktor.http.content.OutgoingContent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.invoke +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import splitties.init.appCtx +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.minusAssign +import kotlin.concurrent.atomics.plusAssign +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +@OptIn(ExperimentalAtomicApi::class) +class TransferUploaderV2( + override val startRequest: StartUploadRequest, + private val uploadManager: UploadV2Manager, + private val transferManager: TransferManager, + private val fileChunkSizeManager: FileChunkSizeManager, + private val transfer: Transfer, + private val thumbnailsLocalStorage: ThumbnailsLocalStorage, +) : TransferUploader { + + private val pickedFiles: List = startRequest.files + + init { + require(pickedFiles.size == transfer.files.size) + } + + private val uploadedBytes = AtomicLong(value = 0) + private val uploadedBytesUpdates = MutableSharedFlow(extraBufferCapacity = 1, replay = 1).also { + it.tryEmit(Unit) + } + + private val filesToUploadMetadata = pickedFiles.mapIndexed { index, pickedFile -> + FileToUploadMetaData( + pickedFile = pickedFile, + chunkConfig = fileChunkSizeManager.computeChunkConfig(fileSize = pickedFile.size), + uuid = transfer.files[index].id, + ) + } + + private class FileToUploadMetaData( + val pickedFile: PickedFile, + val chunkConfig: FileChunkSizeManager.ChunkConfig, + val uuid: String, + ) { + + val chunksEtags = Array(chunkConfig.totalChunks) { null } + var thumbnailSaved = false + } + + /** + * Returns the transfer UUID once the upload completes successfully. + */ + override suspend fun uploadRemainderOrThrow(updateState: (UploadState.Ongoing) -> Unit) = autoCancelScope { + launch { + uploadedBytesUpdates.collect { _ -> + updateState(newUploadState(uploadedBytes.load())) + } + } + uploadRemainderOrThrow() + } + + private fun newUploadState(uploadedBytes: Long): UploadState.Ongoing { + return UploadState.Ongoing.Uploading( + uploadedBytes = uploadedBytes, + status = Status.InProgress, + info = startRequest.info + ) + } + + private suspend fun uploadRemainderOrThrow(): TransferUploader.UploadResult { + uploadFiles() + val linkId = uploadManager.finalizeTransferAndGetLinkUuid(transferId = transfer.id) + val transferUuid = transfer.id + + thumbnailsLocalStorage.renameOngoingThumbnailsFolderWith(transfer.id) + + transferManager.updateTransferFilesThumbnails( + transferUUID = transferUuid, + thumbnailRootPath = thumbnailsLocalStorage.getThumbnailsFolderFor(transferUuid).toString(), + ) + + return TransferUploader.UploadResult(transferId = transferUuid, linkId = linkId) + } + + private suspend fun uploadFiles() { + filesToUploadMetadata.forEach { + saveThumbnailAndUploadFileIfNeeded(it) + } + } + + private val contentResolver = appCtx.contentResolver + + private suspend fun saveThumbnailAndUploadFileIfNeeded(metadata: FileToUploadMetaData) { + + val allChunksUploaded = metadata.chunksEtags.none { it == null } + if (allChunksUploaded) return + + val targetFileUri: Uri = metadata.pickedFile.uri + val fileUUID: String = metadata.uuid + SentryLog.i(TAG, "start upload file $fileUUID, with size ${metadata.pickedFile.size}") + + if (metadata.thumbnailSaved.not()) targetFileUri.getMimeType()?.let { mimeType -> + thumbnailsLocalStorage.generateThumbnailFor( + fileUri = targetFileUri, + fileName = fileUUID, + extension = mimeType.substringAfterLast('/'), + ) + metadata.thumbnailSaved = true + } + uploadFileInChunks(metadata = metadata) + } + + private fun Uri.getMimeType(): String? { + return when (scheme) { + ContentResolver.SCHEME_CONTENT -> contentResolver.getType(this) + ContentResolver.SCHEME_FILE -> MimeTypeMap.getSingleton().getMimeTypeFromExtension( + MimeTypeMap.getFileExtensionFromUrl(toString()).lowercase() + ) + else -> null + } + } + + private suspend fun uploadFileInChunks(metadata: FileToUploadMetaData) = Dispatchers.IO { + + val chunkConfig = metadata.chunkConfig + val totalChunks = chunkConfig.totalChunks + + val requestSemaphore = Semaphore(chunkConfig.parallelChunks) + + val lastChunkIndex = totalChunks - 1 + + SentryLog.d(TAG, "$chunkConfig") + + val etags = coroutineScope { + List(totalChunks) { chunkIndex -> + async { + requestSemaphore.withPermit { + uploadChunkIfNeeded( + metadata = metadata, + chunkIndex = chunkIndex, + isLastChunk = chunkIndex == lastChunkIndex + ) + } + } + }.awaitAll().sortedBy { it.chunkIndex } + } + uploadManager.finalizeFileUploadedInChunks(transfer.id, metadata.uuid, etags) + } + + private suspend fun uploadChunkIfNeeded(metadata: FileToUploadMetaData, chunkIndex: Int, isLastChunk: Boolean): ChunkEtag { + val fileUuid = metadata.uuid + when (val etag = metadata.chunksEtags[chunkIndex]) { + null -> {} + else -> { + SentryLog.d(TAG, "skipping chunk #$chunkIndex since it's already been uploaded (file $fileUuid)") + return etag + } + } + + val chunkSize = metadata.chunkConfig.fileChunkSize + val totalFileSize = metadata.pickedFile.size + val chunkedContent = metadata.pickedFile.uri.toOutgoingContent( + offset = chunkSize * chunkIndex, + length = if (isLastChunk) { + (totalFileSize % chunkSize).let { if (it == 0L) chunkSize else it } + } else { + chunkSize + } + ) + val etag = withRetries { isRetrying -> + if (isRetrying) { + val etag = metadata.chunksEtags[chunkIndex] + if (etag != null) { + SentryLog.d(TAG, "no need to retry") + SentryLog.d(TAG, "skipping chunk #$chunkIndex since it's already been uploaded (file $fileUuid)") + return etag + } + } + SentryLog.i(TAG, "Uploading chunk #$chunkIndex of $fileUuid") { + uploadChunk( + fileUUID = fileUuid, + chunkIndex = chunkIndex, + data = chunkedContent + ) + } + } + metadata.chunksEtags[chunkIndex] = etag + return etag + } + + private suspend inline fun withRetries(block: (isRetrying: Boolean) -> R): R { + val maxGiveUpDelay = 40.seconds + val maxDelayBetweenRetries = 10.seconds + val minDelayBetweenRetries = 500.milliseconds + val attemptTimeMark = TimeSource.Monotonic.markNow() + var attemptNumber = 0 + while (true) { + try { + attemptNumber++ + return block(attemptNumber > 1) + } catch (e: NetworkException) { + val timeUntilGiveUp = maxGiveUpDelay - attemptTimeMark.elapsedNow() + val retryIn = (minDelayBetweenRetries * attemptNumber).coerceAtMost(maxDelayBetweenRetries) + if (retryIn > timeUntilGiveUp) throw e + delay(retryIn) + } + } + } + + private suspend fun uploadChunk( + fileUUID: String, + chunkIndex: Int, + data: OutgoingContent.WriteChannelContent, + ): ChunkEtag { + var oldBytesSentTotal = 0L + try { + val chunkNumber = chunkIndex + 1 + val etag = uploadManager.uploadFileChunk( + transferId = transfer.id, + fileId = fileUUID, + chunkIndex = chunkNumber, + data = data, + onUpload = { bytesSentTotal, _ -> + val bytesJustSent = bytesSentTotal - oldBytesSentTotal + oldBytesSentTotal = bytesSentTotal + uploadedBytes += bytesJustSent + uploadedBytesUpdates.tryEmit(Unit) + } + ) + return ChunkEtag(etag, chunkNumber) + } catch (t: Throwable) { + uploadedBytes -= oldBytesSentTotal + uploadedBytesUpdates.tryEmit(Unit) + throw t + } + } + + private companion object { + private val TAG = TransferUploaderV2::class.java.simpleName + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionManager.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionManager.kt index 5e8da332d..8036447af 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionManager.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionManager.kt @@ -23,13 +23,16 @@ import androidx.compose.runtime.LongState import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.mutableLongStateOf import androidx.work.WorkManager +import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.withPartialWakeLock import com.infomaniak.core.network.NetworkAvailability import com.infomaniak.core.sentry.SentryLog import com.infomaniak.multiplatform_swisstransfer.SharedApiUrlCreator +import com.infomaniak.multiplatform_swisstransfer.managers.AccountManager import com.infomaniak.multiplatform_swisstransfer.managers.InMemoryUploadManager import com.infomaniak.multiplatform_swisstransfer.managers.TransferManager +import com.infomaniak.multiplatform_swisstransfer.managers.UploadV2Manager import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException import com.infomaniak.multiplatform_swisstransfer.utils.FileUtils import com.infomaniak.swisstransfer.ui.screen.newtransfer.PickedFile @@ -63,7 +66,6 @@ import kotlinx.coroutines.yield import splitties.coroutines.raceOf import splitties.coroutines.repeatWhileActive import splitties.experimental.ExperimentalSplittiesApi -import splitties.init.appCtx import javax.inject.Inject import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -71,7 +73,9 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource class UploadSessionManager @Inject constructor( - private val uploadManager: InMemoryUploadManager, + private val legacyUploadManager: InMemoryUploadManager, + private val uploadManager: UploadV2Manager, + accountManager: AccountManager, private val transferManager: TransferManager, private val sharedApiUrlCreator: SharedApiUrlCreator, private val notificationsUtils: NotificationsUtils, @@ -80,6 +84,8 @@ class UploadSessionManager @Inject constructor( private val workManager: WorkManager, ) { + private val currentUser = accountManager.currentUser + suspend fun handleNewTransfer( startRequestWithTheoreticalSizes: StartUploadRequest, uploadState: MutableStateFlow, @@ -161,44 +167,72 @@ class UploadSessionManager @Inject constructor( return } - val transferUuid = tryCompletingUnlessCancelled( + val uploadResult: TransferUploader.UploadResult? = tryCompletingUnlessCancelled( waitForCancellationSignal = { cancelTransferSignals.receive() }, valueIfCancelled = null ) { - TransferUploader( - startRequest = startRequest, - uploadManager = uploadManager, - transferManager = transferManager, - fileChunkSizeManager = fileChunkSizeManager, - request = request, - destination = destination, - thumbnailsLocalStorage = thumbnailsLocalStorage, - ).getUuidFromUploadWithRetries( + val uploader: TransferUploader = when (destination) { + is Xor.First -> TransferUploaderV1( + startRequest = startRequest, + uploadManager = legacyUploadManager, + transferManager = transferManager, + fileChunkSizeManager = fileChunkSizeManager, + request = request, + destination = destination.value, + thumbnailsLocalStorage = thumbnailsLocalStorage, + ) + is Xor.Second -> TransferUploaderV2( + startRequest = startRequest, + uploadManager = uploadManager, + transferManager = transferManager, + fileChunkSizeManager = fileChunkSizeManager, + transfer = destination.value, + thumbnailsLocalStorage = thumbnailsLocalStorage, + ) + } + uploader.getUuidFromUploadWithRetries( uploadState = uploadState, shouldRetry = ::shouldRetry, ) } - currentState = if (transferUuid != null) { - val url = sharedApiUrlCreator.shareTransferUrl(transferUuid) + currentState = if (uploadResult != null) { + val url = when (destination) { + is Xor.First -> sharedApiUrlCreator.shareTransferUrl(transferUUID = uploadResult.transferId) + is Xor.Second -> sharedApiUrlCreator.shareTransferV2Url(linkUUID = uploadResult.linkId!!) + } val transferType = startRequest.info.type removeAllFiles() notificationsUtils.uploadSucceeded( transferType = transferType, - transferUuid = transferUuid, + transferUuid = uploadResult.transferId, transferUrl = url, ) UploadState.Complete( transferType = transferType, - transferUuid = transferUuid, + transferUuid = uploadResult.transferId, transferUrl = url, ) } else { - AbandonedTransferCleanupWorker.schedule(workManager, destination.container.uuid).onFailure { - SentryLog.wtf(TAG, "Failed to schedule AbandonedTransferCleanupWorker!", it) + when (destination) { + is Xor.First -> { + AbandonedTransferCleanupWorker.schedule(workManager, destination.value.container.uuid).onFailure { + SentryLog.wtf(TAG, "Failed to schedule AbandonedTransferCleanupWorker!", it) + } + } + is Xor.Second -> { + AbandonedTransferV2CleanupWorker.schedule( + workManager = workManager, + userId = currentUser!!.id, + transferId = destination.value.id, + failed = false + ).onFailure { + SentryLog.wtf(TAG, "Failed to schedule AbandonedTransferV2CleanupWorker!", it) + } + } } null } @@ -210,7 +244,7 @@ class UploadSessionManager @Inject constructor( private suspend fun TransferUploader.getUuidFromUploadWithRetries( uploadState: MutableStateFlow, shouldRetry: suspend () -> Boolean, - ): String? { + ): TransferUploader.UploadResult? { repeatWhileActive retryLoop@{ @Suppress("VariableNeverRead") // Only here for the setter. var currentState by uploadState::value @@ -292,13 +326,14 @@ class UploadSessionManager @Inject constructor( valueIfCancelled }) - private val isInternetConnectedFlow: SharedFlow = NetworkAvailability( - context = appCtx - ).isNetworkAvailable.conflate().distinctUntilChanged().shareIn( - scope = CoroutineScope(Dispatchers.Default), - started = SharingStarted.WhileSubscribed(), - replay = 1 - ) + private val isInternetConnectedFlow: SharedFlow = NetworkAvailability().isNetworkAvailable + .conflate() + .distinctUntilChanged() + .shareIn( + scope = CoroutineScope(Dispatchers.Default), + started = SharingStarted.WhileSubscribed(), + replay = 1 + ) } private const val EXPECTED_CHUNK_SIZE = 50L * 1_024 * 1_024 // 50 MB diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterInterface.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterInterface.kt new file mode 100644 index 000000000..f275c8442 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterInterface.kt @@ -0,0 +1,51 @@ +/* + * Infomaniak SwissTransfer - 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.swisstransfer.upload + +import com.infomaniak.core.common.Xor +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.Transfer +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadDestination +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadSessionRequest + +abstract class UploadSessionStarter { + + suspend fun tryStarting(request: StartUploadRequest): Result { + val uploadSessionRequest = request.params.toUploadSessionRequest( + filesMetadata = request.files.map { it.toFileUploadMetaData() } + ) + return tryStarting(uploadSessionRequest) + } + + protected abstract suspend fun tryStarting(sessionRequest: UploadSessionRequest): Result + + sealed interface Result { + + data class Success( + val request: UploadSessionRequest, + val destination: Xor, + ) : Result + + data object EmailValidationRequired : Result + + sealed interface Issue : Result + data object RestrictedLocation : Issue + data object AppIntegrityIssue : Issue + data object NetworkIssue : Issue + data class OtherIssue(val t: Throwable) : Issue + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarter.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV1.kt similarity index 76% rename from app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarter.kt rename to app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV1.kt index 3c908e483..d30a7d3d8 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarter.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV1.kt @@ -21,37 +21,29 @@ import com.infomaniak.core.appintegrity.AppIntegrityManager import com.infomaniak.core.appintegrity.AppIntegrityManager.Companion.APP_INTEGRITY_MANAGER_TAG import com.infomaniak.core.appintegrity.exceptions.IntegrityException import com.infomaniak.core.appintegrity.exceptions.NetworkException +import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.sentry.SentryLog import com.infomaniak.multiplatform_swisstransfer.SharedApiUrlCreator -import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadDestination import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadSessionRequest import com.infomaniak.multiplatform_swisstransfer.managers.InMemoryUploadManager import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ContainerErrorsException import com.infomaniak.swisstransfer.BuildConfig -import javax.inject.Inject import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException as KmpNetworkException -class UploadSessionStarter @Inject constructor( +class UploadSessionStarterV1( private val appIntegrityManager: AppIntegrityManager, private val sharedApiUrlCreator: SharedApiUrlCreator, private val uploadManager: InMemoryUploadManager, -) { +) : UploadSessionStarter() { - suspend fun tryStarting(request: StartUploadRequest): Result { - val uploadSessionRequest = request.params.toUploadSessionRequest( - filesMetadata = request.files.map { it.toFileUploadMetaData() } - ) - return tryStarting(uploadSessionRequest) - } - - private suspend fun tryStarting(sessionRequest: UploadSessionRequest): Result = runCatching { + override suspend fun tryStarting(sessionRequest: UploadSessionRequest): Result = runCatching { val destination = uploadManager.startUploadSession( request = sessionRequest, attestationHeaderName = AppIntegrityManager.ATTESTATION_TOKEN_HEADER, generateAttestationToken = { fetchNewAttestationToken() }, ) - Result.Success(sessionRequest, destination) + Result.Success(sessionRequest, Xor.First(destination)) }.cancellable().getOrElse { t -> SentryLog.w(TAG, "Throwable while trying to start the upload session", t) when (t) { @@ -84,22 +76,6 @@ class UploadSessionStarter @Inject constructor( return attestationToken } //endregion - - sealed interface Result { - - data class Success( - val request: UploadSessionRequest, - val destination: UploadDestination - ) : Result - - data object EmailValidationRequired : Result - - sealed interface Issue : Result - data object RestrictedLocation : Issue - data object AppIntegrityIssue : Issue - data object NetworkIssue : Issue - data class OtherIssue(val t: Throwable) : Issue - } } private const val TAG = "UploadSessionStarter" diff --git a/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV2.kt b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV2.kt new file mode 100644 index 000000000..e26be97e0 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/upload/UploadSessionStarterV2.kt @@ -0,0 +1,52 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2025-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.swisstransfer.upload + +import com.infomaniak.core.appintegrity.exceptions.NetworkException +import com.infomaniak.core.common.Xor +import com.infomaniak.core.common.cancellable +import com.infomaniak.core.sentry.SentryLog +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadSessionRequest +import com.infomaniak.multiplatform_swisstransfer.managers.UploadV2Manager +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ContainerErrorsException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException as KmpNetworkException + +class UploadSessionStarterV2( + private val uploadManager: UploadV2Manager, +) : UploadSessionStarter() { + + override suspend fun tryStarting(sessionRequest: UploadSessionRequest): Result = runCatching { + val transfer = uploadManager.prepareTransfer(sessionRequest) + Result.Success(sessionRequest, Xor.Second(transfer)) + }.cancellable().getOrElse { t -> + SentryLog.w(TAG, "Throwable while trying to start the upload session", t) + when (t) { + is NetworkException, is KmpNetworkException -> Result.NetworkIssue + //TODO[ST-v2]: Will we still need that when guest mode comes to the v2 API? + // is IntegrityException -> Result.AppIntegrityIssue + // is ContainerErrorsException.EmailValidationRequired -> Result.EmailValidationRequired + is ContainerErrorsException.DomainBlockedException -> Result.RestrictedLocation + else -> { + SentryLog.e(TAG, "Unexpected issue while starting the upload session", t) + Result.OtherIssue(t) + } + } + } +} + +private const val TAG = "UploadSessionStarterV2" diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ce06fa621..b6f960d1c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -185,6 +185,7 @@ %s tilbage %s tilbage + Giv din overførsel en titel E-mail Link QR-kode diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 762da0c74..df5684410 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -185,6 +185,7 @@ %s übrig %s übrig + Gib deinem Transfer einen Titel E-Mail Link QR Code diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6d04f54d0..295ef3fcc 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -185,6 +185,7 @@ %s απομένει %s απομένουν + Δώσε έναν τίτλο στη μεταφορά σου Email Σύνδεσμος Κωδικός QR diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e3fdeb013..edc0c9b6b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -185,6 +185,7 @@ Queda %s Quedan %s + Pon un título a tu transferencia Correo electrónico Enlace QR Code diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 049e6182f..9609b95d2 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -185,6 +185,7 @@ %s jäljellä %s jäljellä + Anna siirrollesi otsikko Sähköposti Linkki QR-koodi diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 581364eb5..12b31c0dc 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -185,6 +185,7 @@ %s restant %s restants + Donne un titre à ton transfert E-mail Lien QR Code diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7a8734d43..08a583d11 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -185,6 +185,7 @@ %s rimasto %s rimasti + Dai un titolo al tuo trasferimento Email Link QR Code diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index edd2c8e60..446d4fed3 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -185,6 +185,7 @@ %s igjen %s igjen + Gi overføringen din en tittel E-post Lenke QR-kode diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 711bd2fba..13db06fbe 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -185,6 +185,7 @@ %s over %s over + Geef je transfer een titel E-mail Link QR-code diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ac8017f0d..35e047b3f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -195,6 +195,7 @@ %s pozostało %s pozostało + Nadaj tytuł swojemu transferowi E-mail Link Kod QR diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e5e8d6671..686735ca7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -185,6 +185,7 @@ %s restante %s restantes + Dê um título à sua transferência E-mail Ligação Código QR diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 9728e80fe..8a98e2f50 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -185,6 +185,7 @@ %s kvar %s kvar + Ge din överföring en titel E-post Länk QR-kod diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 291fe2810..e218287fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,6 +190,7 @@ %s left %s left + Give a title to your transfer Email Link QR Code diff --git a/app/src/test/java/com/infomaniak/swisstransfer/TransferSectionGroupingTest.kt b/app/src/test/java/com/infomaniak/swisstransfer/TransferSectionGroupingTest.kt index 13de8360f..72d424e4e 100644 --- a/app/src/test/java/com/infomaniak/swisstransfer/TransferSectionGroupingTest.kt +++ b/app/src/test/java/com/infomaniak/swisstransfer/TransferSectionGroupingTest.kt @@ -149,6 +149,7 @@ class TransferSectionGroupingTest { fun Long.toTransferUi(): TransferUi { return TransferUi( uuid = "", + linkId = null, createdDateTimestamp = this, expirationDateTimestamp = 0, sizeUploaded = 0, @@ -157,6 +158,7 @@ class TransferSectionGroupingTest { message = "", password = "", files = emptyList(), + apiSource = TransferUi.ApiSource.V1 ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03f2f192e..fe1cfdc03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ coreSplashscreen = "1.2.0" desugarJDK = "2.1.5" qrose = "1.0.1" room = "2.8.4" -swisstransfer = "6.2.0" +swisstransfer = "7.0.0-SNAPSHOT" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }