From 081d4d29f3f189520c64fd85c5ff4361b83120cc Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Tue, 3 Mar 2026 21:17:31 +0500 Subject: [PATCH] Initial implementation of scan-to-send --- app/build.gradle.kts | 1 + .../drop/app/data/SendFilesSubscriberImpl.kt | 38 +++++- .../drop/app/data/SenderFileDataImpl.kt | 4 + .../app/data/repository/SendSessionRepo.kt | 49 ++++++-- .../dev/arkbuilders/drop/app/di/AppModule.kt | 4 +- .../drop/app/di/ViewModelsModule.kt | 10 +- .../drop/app/domain/model/ISendSession.kt | 16 +++ .../drop/app/domain/model/SendSession.kt | 21 +++- .../drop/app/domain/model/SendToSession.kt | 24 ++++ .../usecase/ReadyToReceiveFilesUseCase.kt | 50 ++++++++ .../app/domain/usecase/SendFilesToUseCase.kt | 81 ++++++++++++ .../receive/components/ReceiveScanningCard.kt | 119 ++++++++++-------- .../drop/app/presentation/send/Send.kt | 37 +++++- .../app/presentation/send/SendScreenState.kt | 23 +++- .../app/presentation/send/SendViewModel.kt | 80 ++++++++++-- gradle/libs.versions.toml | 3 +- 16 files changed, 480 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/dev/arkbuilders/drop/app/domain/model/ISendSession.kt create mode 100644 app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendToSession.kt create mode 100644 app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReadyToReceiveFilesUseCase.kt create mode 100644 app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesToUseCase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 15edf56..a442aa0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,6 +184,7 @@ dependencies { implementation(libs.io.koin.core) implementation(libs.io.koin.android) implementation(libs.io.koin.compose) + implementation(libs.io.koin.androidx.compose) implementation(libs.io.koin.test) } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt index bbc79b4..0cf26c5 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt @@ -3,6 +3,9 @@ package dev.arkbuilders.drop.app.data import dev.arkbuilders.drop.SendFilesConnectingEvent import dev.arkbuilders.drop.SendFilesSendingEvent import dev.arkbuilders.drop.SendFilesSubscriber +import dev.arkbuilders.drop.SendFilesToConnectingEvent +import dev.arkbuilders.drop.SendFilesToSendingEvent +import dev.arkbuilders.drop.SendFilesToSubscriber import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -55,8 +58,39 @@ class SendFilesSubscriberImpl : SendFilesSubscriber { receiverAvatar = event.receiver.avatarB64, ) } +} + +class SendFilesToSubscriberImpl : SendFilesToSubscriber { + private val id = UUID.randomUUID().toString() + + private val _progress = MutableStateFlow(SendingProgress()) + val progress: StateFlow = _progress.asStateFlow() + + override fun getId() = id + + override fun log(message: String) { + Timber.d(message) + } + + override fun notifyConnecting(event: SendFilesToConnectingEvent) { + log("Connected to receiver: ${event.receiver.name}") + + _progress.value = + _progress.value.copy( + isConnected = true, + receiverName = event.receiver.name, + receiverAvatar = event.receiver.avatarB64, + ) + } + + override fun notifySending(event: SendFilesToSendingEvent) { + log("Sending progress: ${event.name} - sent: ${event.sent}, remaining: ${event.remaining}") - fun reset() { - _progress.value = SendingProgress() + _progress.value = + _progress.value.copy( + fileName = event.name, + sent = event.sent, + remaining = event.remaining, + ) } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt index c933b80..c499a8a 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt @@ -42,6 +42,10 @@ class SenderFileDataImpl( } } + override fun isEmpty(): Boolean { + return false + } + override fun len(): ULong { initialize() return totalLength diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt index 07beed7..398e36b 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt @@ -2,11 +2,15 @@ package dev.arkbuilders.drop.app.data.repository import android.net.Uri import dev.arkbuilders.drop.app.data.SendFilesSubscriberImpl +import dev.arkbuilders.drop.app.data.SendFilesToSubscriberImpl import dev.arkbuilders.drop.app.domain.ResourcesHelper import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.ISendSession import dev.arkbuilders.drop.app.domain.model.SendSession +import dev.arkbuilders.drop.app.domain.model.SendToSession import dev.arkbuilders.drop.app.domain.model.TransferStatus import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.domain.usecase.SendFilesToUseCase import dev.arkbuilders.drop.app.domain.usecase.SendFilesUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,15 +23,16 @@ import timber.log.Timber class SendSessionRepo( private val sendUseCase: SendFilesUseCase, + private val sendFilesToUseCase: SendFilesToUseCase, private val resourcesHelper: ResourcesHelper, private val transferSessionRepository: TransferSessionRepo, ) { // Keep references to active sessions here so file transfers continue even if the ViewModel dies - private val activeSessions = mutableListOf() + private val activeSessions = mutableListOf() private val activeSessionsMutex = Mutex() private val cancelScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - suspend fun sendFiles(fileUris: List): SendSession? = + suspend fun sendFiles(fileUris: List): ISendSession? = withContext(Dispatchers.IO) { cleanupFinishedSessions() @@ -50,13 +55,42 @@ class SendSessionRepo( ) } + suspend fun sendFilesTo( + ticket: String, + confirmation: UByte, + fileUris: List, + ): ISendSession? = + withContext(Dispatchers.IO) { + cleanupFinishedSessions() + + sendFilesToUseCase.invoke(ticket, confirmation, fileUris).fold( + onSuccess = { bubble -> + val subscriber = + SendFilesToSubscriberImpl().also { subscriber -> + bubble.subscribe(subscriber) + } + + val session = SendToSession(bubble, subscriber) + activeSessionsMutex.withLock { + activeSessions.add(session) + } + + bubble.start() + return@withContext session + }, + onFailure = { + return@withContext null + }, + ) + } + suspend fun recordSendCompletion( fileUris: List, - session: SendSession, + session: ISendSession, ) { try { cleanupFinishedSessions() - val progress = session.subscriber.progress.value + val progress = session.progress.value val receiverName = progress.receiverName val receiverAvatar = progress.receiverAvatar @@ -79,14 +113,13 @@ class SendSessionRepo( } } - fun cancelSend(session: SendSession) { + fun cancelSend(session: ISendSession) { cancelScope.launch { try { activeSessionsMutex.withLock { activeSessions.remove(session) } - session.bubble.unsubscribe(session.subscriber) - session.bubble.cancel() + session.cancel() } catch (e: Throwable) { Timber.e("Error cancelling send ${e.message}") } @@ -95,6 +128,6 @@ class SendSessionRepo( private suspend fun cleanupFinishedSessions() = activeSessionsMutex.withLock { - activeSessions.removeAll { it.bubble.isFinished() } + activeSessions.removeAll { it.isFinished() } } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt b/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt index e5b71bc..0373b20 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt @@ -19,6 +19,7 @@ import dev.arkbuilders.drop.app.domain.repository.NetworkStatus import dev.arkbuilders.drop.app.domain.repository.ProfileRepo import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo import dev.arkbuilders.drop.app.domain.usecase.ReceiveFilesUseCase +import dev.arkbuilders.drop.app.domain.usecase.SendFilesToUseCase import dev.arkbuilders.drop.app.domain.usecase.SendFilesUseCase import org.koin.dsl.module @@ -33,7 +34,7 @@ val appModule = single { AvatarHelperImpl(get()) } single { ProfileLocalDataSource(get(), get()) } single { TransferSessionLocalDataSource(get()) } - single { SendSessionRepo(get(), get(), get()) } + single { SendSessionRepo(get(), get(), get(), get()) } single { ReceiveSessionRepo(get(), get(), get()) } factory { val db: Database = get() @@ -41,4 +42,5 @@ val appModule = } factory { SendFilesUseCase(get(), get(), get()) } factory { ReceiveFilesUseCase(get()) } + factory { SendFilesToUseCase(get(), get(), get()) } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt b/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt index ab8f0e8..4931b46 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt @@ -6,6 +6,7 @@ import dev.arkbuilders.drop.app.presentation.profile.EditProfileViewModel import dev.arkbuilders.drop.app.presentation.receive.ReceiveViewModel import dev.arkbuilders.drop.app.presentation.send.SendViewModel import org.koin.core.module.dsl.viewModel +import org.koin.core.parameter.parametersOf import org.koin.dsl.module val viewModelsModule = @@ -14,5 +15,12 @@ val viewModelsModule = viewModel { HomeViewModel(get(), get(), get()) } viewModel { EditProfileViewModel(get(), get()) } viewModel { ReceiveViewModel(get(), get()) } - viewModel { SendViewModel(get(), get(), get()) } + viewModel { (isScanToSend: Boolean) -> + SendViewModel( + get { parametersOf(isScanToSend) }, + get(), + get(), + get(), + ) + } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ISendSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ISendSession.kt new file mode 100644 index 0000000..b483f56 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ISendSession.kt @@ -0,0 +1,16 @@ +package dev.arkbuilders.drop.app.domain.model + +import dev.arkbuilders.drop.app.data.SendingProgress +import kotlinx.coroutines.flow.StateFlow + +interface ISendSession { + fun isFinished(): Boolean + + suspend fun cancel() + + fun ticket(): String? + + fun confirmation(): UByte? + + val progress: StateFlow +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt index 25208bb..8b7db45 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt @@ -2,8 +2,23 @@ package dev.arkbuilders.drop.app.domain.model import dev.arkbuilders.drop.SendFilesBubble import dev.arkbuilders.drop.app.data.SendFilesSubscriberImpl +import dev.arkbuilders.drop.app.data.SendingProgress +import kotlinx.coroutines.flow.StateFlow class SendSession( - val bubble: SendFilesBubble, - val subscriber: SendFilesSubscriberImpl, -) + private val bubble: SendFilesBubble, + private val subscriber: SendFilesSubscriberImpl, +) : ISendSession { + override fun isFinished(): Boolean = bubble.isFinished() + + override suspend fun cancel() { + bubble.cancel() + bubble.unsubscribe(subscriber) + } + + override fun ticket(): String? = bubble.getTicket() + + override fun confirmation(): UByte? = bubble.getConfirmation() + + override val progress: StateFlow = subscriber.progress +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendToSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendToSession.kt new file mode 100644 index 0000000..6ac60bd --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendToSession.kt @@ -0,0 +1,24 @@ +package dev.arkbuilders.drop.app.domain.model + +import dev.arkbuilders.drop.SendFilesToBubble +import dev.arkbuilders.drop.app.data.SendFilesToSubscriberImpl +import dev.arkbuilders.drop.app.data.SendingProgress +import kotlinx.coroutines.flow.StateFlow + +class SendToSession( + private val bubble: SendFilesToBubble, + private val subscriber: SendFilesToSubscriberImpl, +) : ISendSession { + override fun isFinished(): Boolean = bubble.isFinished() + + override suspend fun cancel() { + bubble.cancel() + bubble.unsubscribe(subscriber) + } + + override fun ticket(): String? = null + + override fun confirmation(): UByte? = null + + override val progress: StateFlow = subscriber.progress +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReadyToReceiveFilesUseCase.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReadyToReceiveFilesUseCase.kt new file mode 100644 index 0000000..c50f70c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReadyToReceiveFilesUseCase.kt @@ -0,0 +1,50 @@ +package dev.arkbuilders.drop.app.domain.usecase + +import dev.arkbuilders.drop.ReadyToReceiveRequest +import dev.arkbuilders.drop.ReceiverConfig +import dev.arkbuilders.drop.ReceiverProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.readyToReceive +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.text.ifEmpty + +class ReadyToReceiveFilesUseCase( + private val profileRepo: ProfileRepo, +) { + suspend operator fun invoke() = + withContext(Dispatchers.IO) { + runCatching { + Timber.d("Starting file receive") + + val profile = profileRepo.getCurrentProfile() + val receiverProfile = + ReceiverProfile( + name = profile.name.ifEmpty { "Anonymous" }, + avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, + ) + + val request = + ReadyToReceiveRequest( + profile = receiverProfile, + config = + ReceiverConfig( + chunkSize = 1024u * 512u, + parallelStreams = 4u, + ), + ) + + val bubble = readyToReceive(request) + + Timber.d( + "Receive bubble created with ticket and confirmation: ${ + bubble.getTicket() + } ${bubble.getConfirmation()}", + ) + bubble + }.onFailure { + Timber.e("Error starting file receive ${it.message}") + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesToUseCase.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesToUseCase.kt new file mode 100644 index 0000000..20e573c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesToUseCase.kt @@ -0,0 +1,81 @@ +package dev.arkbuilders.drop.app.domain.usecase + +import android.content.Context +import android.net.Uri +import dev.arkbuilders.drop.SendFilesToBubble +import dev.arkbuilders.drop.SendFilesToRequest +import dev.arkbuilders.drop.SenderConfig +import dev.arkbuilders.drop.SenderFile +import dev.arkbuilders.drop.SenderProfile +import dev.arkbuilders.drop.app.data.SenderFileDataImpl +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.sendFilesTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.text.ifEmpty + +class SendFilesToUseCase( + private val context: Context, + private val profileRepo: ProfileRepo, + private val resourcesHelper: ResourcesHelper, +) { + suspend operator fun invoke( + ticket: String, + confirmation: UByte, + fileUris: List, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + Timber.d("Starting file send for ${fileUris.size} files") + + val profile = profileRepo.getCurrentProfile() + val senderProfile = + SenderProfile( + name = profile.name.ifEmpty { "Anonymous" }, + avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, + ) + + val senderFiles = + fileUris.mapNotNull { uri -> + val fileName = resourcesHelper.getFileName(uri.toString()) + if (fileName != null) { + val fileData = SenderFileDataImpl(context, uri) + SenderFile( + name = fileName, + data = fileData, + ) + } else { + Timber.w("Could not get filename for URI: $uri") + null + } + } + + if (senderFiles.isEmpty()) { + Timber.e("No valid files to send") + error("No valid files to send") + } + + val request = + SendFilesToRequest( + ticket = ticket, + confirmation = confirmation, + profile = senderProfile, + files = senderFiles, + config = + SenderConfig( + chunkSize = 1024u * 512u, + parallelStreams = 4u, + ), + ) + + val bubble = sendFilesTo(request) + + Timber.d("Send bubble created and started") + bubble + }.onFailure { + Timber.e("Error starting file send ${it.message}") + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt index 107bbc1..9c37382 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt @@ -202,58 +202,79 @@ private fun processImageProxy( onQRCodeScanned: (String, UByte) -> Unit, onError: (ReceiveError) -> Unit, ) { - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - val scanner = BarcodeScanning.getClient() + val mediaImage = + imageProxy.image ?: run { + imageProxy.close() + return + } - scanner.process(image) - .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - when (barcode.valueType) { - Barcode.TYPE_TEXT, Barcode.TYPE_URL -> { - barcode.rawValue?.let { value -> - // Parse Drop QR code format: drop://receive?ticket=...&confirmation=... - if (value.startsWith("drop://receive?")) { - try { - val uri = value.toUri() - val ticket = uri.getQueryParameter("ticket") - val confirmationStr = uri.getQueryParameter("confirmation") + val image = + InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees, + ) - if (ticket != null && confirmationStr != null) { - val confirmation = confirmationStr.toUByte() - onQRCodeScanned(ticket, confirmation) - return@addOnSuccessListener - } - } catch (_: Exception) { - onError(ReceiveError.InvalidQRCode) - return@addOnSuccessListener - } - } else { - onError(ReceiveError.InvalidQRCode) - return@addOnSuccessListener - } - } - } - } - } - } - .addOnFailureListener { exception -> - onError( - when { - exception.message?.contains( - "camera", - ignoreCase = true, - ) == true -> ReceiveError.CameraInitializationFailed + BarcodeScanning.getClient() + .process(image) + .addOnSuccessListener { barcodes -> + handleBarcodes(barcodes, onQRCodeScanned) + } + .addOnFailureListener { exception -> + onError(mapScannerError(exception)) + } + .addOnCompleteListener { + imageProxy.close() + } +} - else -> ReceiveError.UnknownError - }, - ) - } - .addOnCompleteListener { - imageProxy.close() - } +private fun handleBarcodes( + barcodes: List, + onQRCodeScanned: (String, UByte) -> Unit, +) { + barcodes.firstNotNullOfOrNull { barcode -> + if (barcode.valueType == Barcode.TYPE_TEXT || + barcode.valueType == Barcode.TYPE_URL + ) { + barcode.rawValue?.let { parseDropQr(it) } + } else { + null + } + }?.let { (ticket, confirmation) -> + onQRCodeScanned(ticket, confirmation) + } +} + +/** + * Parses Drop QR format: + * drop://{action}?ticket=...&confirmation=... + * + * Supported actions: receive, send + * + * @return Pair(ticket, confirmation) or null if invalid + */ +private fun parseDropQr(value: String): Pair? { + if (!value.startsWith("drop://")) return null + + return try { + val uri = value.toUri() + + val ticket = uri.getQueryParameter("ticket") + val confirmation = uri.getQueryParameter("confirmation")?.toUByte() + + if (ticket != null && confirmation != null) { + ticket to confirmation + } else { + null + } + } catch (_: Exception) { + null + } +} + +private fun mapScannerError(exception: Exception): ReceiveError { + return if (exception.message?.contains("camera", ignoreCase = true) == true) { + ReceiveError.CameraInitializationFailed } else { - imageProxy.close() + ReceiveError.UnknownError } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt index 76575ce..3cd29bb 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt @@ -13,19 +13,26 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavController import dev.arkbuilders.drop.app.presentation.components.DropErrorCard import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveLoadingCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveQRCodeScannedCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveScanningCard import dev.arkbuilders.drop.app.presentation.send.components.phase.FileSelectionPhase import dev.arkbuilders.drop.app.presentation.send.components.phase.GeneratingQRPhase import dev.arkbuilders.drop.app.presentation.send.components.phase.TransferCompletePhase import dev.arkbuilders.drop.app.presentation.send.components.phase.TransferringPhase import dev.arkbuilders.drop.app.presentation.send.components.phase.WaitingForReceiverPhase -import org.koin.compose.koinInject +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @OptIn(ExperimentalMaterial3Api::class) @Composable fun Send(navController: NavController) { - val viewModel: SendViewModel = koinInject() + val viewModel: SendViewModel = + koinViewModel { + parametersOf(true) + } val state by viewModel.collectAsState() @@ -78,6 +85,32 @@ fun Send(navController: NavController) { ) } + is SendScreenState.Scanning -> { + ReceiveScanningCard( + onQRCodeScanned = { ticket, confirmation -> + viewModel.onQrCodeScanned(ticket, confirmation) + }, + onError = { error -> + }, + onStopScanning = { }, + onEnterManually = { }, + ) + } + + is SendScreenState.QRCodeScanned -> { + ReceiveQRCodeScannedCard( + onAccept = { + viewModel.onAccept() + }, + onScanAgain = { + }, + ) + } + + is SendScreenState.Connecting -> { + ReceiveLoadingCard(message = "Connecting to receiver...") + } + is SendScreenState.GeneratingQR -> { GeneratingQRPhase(onCancel = { viewModel.onCancelQrGeneration() }) } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt index 0193c5f..ad7dfa8 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt @@ -1,7 +1,7 @@ package dev.arkbuilders.drop.app.presentation.send import android.graphics.Bitmap -import dev.arkbuilders.drop.app.domain.model.SendSession +import dev.arkbuilders.drop.app.domain.model.ISendSession sealed class SendScreenState { data class FileSelection( @@ -14,15 +14,28 @@ sealed class SendScreenState { val files: List, ) : SendScreenState() + data class Scanning(val files: List) : SendScreenState() + + data class QRCodeScanned( + val ticket: String, + val confirmation: UByte, + val files: List, + ) : SendScreenState() + + data class Connecting( + val session: ISendSession, + val files: List, + ) : SendScreenState() + data class WaitingForReceiver( - val session: SendSession, + val session: ISendSession, val files: List, val qrBitmap: Bitmap, val copyString: String, ) : SendScreenState() data class Transfer( - val session: SendSession, + val session: ISendSession, val files: List, val isConnected: Boolean = false, val receiverName: String = "", @@ -37,12 +50,12 @@ sealed class SendScreenState { ) : SendScreenState() data class Complete( - val session: SendSession, + val session: ISendSession, val files: List, ) : SendScreenState() data class Error( - val session: SendSession? = null, + val session: ISendSession? = null, val files: List? = null, val error: SendException, ) : SendScreenState() diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt index b34813c..1e10298 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt @@ -12,7 +12,7 @@ import com.google.zxing.common.BitMatrix import com.google.zxing.qrcode.QRCodeWriter import dev.arkbuilders.drop.app.data.repository.SendSessionRepo import dev.arkbuilders.drop.app.domain.ResourcesHelper -import dev.arkbuilders.drop.app.domain.model.SendSession +import dev.arkbuilders.drop.app.domain.model.ISendSession import dev.arkbuilders.drop.app.domain.repository.NetworkStatus import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn @@ -28,6 +28,7 @@ import kotlin.collections.plus import kotlin.collections.sumOf class SendViewModel( + private val isScanToSend: Boolean, private val resourcesHelper: ResourcesHelper, private val networkStatus: NetworkStatus, private val sendSessionRepo: SendSessionRepo, @@ -68,8 +69,71 @@ class SendViewModel( } } + fun onStartTransferScan() = + intent { + val s = state + if (s !is SendScreenState.FileSelection) { + return@intent + } + reduce { + SendScreenState.Scanning(s.files) + } + } + + fun onQrCodeScanned( + ticket: String, + confirmation: UByte, + ) = intent { + val s = state + if (s !is SendScreenState.Scanning) { + return@intent + } + + reduce { + SendScreenState.QRCodeScanned(ticket, confirmation, s.files) + } + } + + fun onAccept() = + intent { + val s = state + if (s !is SendScreenState.QRCodeScanned) { + return@intent + } + val ticket = s.ticket + val confirmation = s.confirmation + + val session = + sendSessionRepo.sendFilesTo( + ticket, + confirmation, + s.files.map { it.toUri() }, + ) + + if (session != null) { + listenToSendProgress(session) + monitorTransferCompletion(session) + reduce { + SendScreenState.Connecting(session, s.files) + } + } else { + reduce { + SendScreenState.Error( + session = session, + files = s.files, + error = SendException.TransferInitializationFailed, + ) + } + } + } + fun onStartTransfer() = intent { + if (isScanToSend) { + onStartTransferScan() + return@intent + } + val s = state if (s !is SendScreenState.FileSelection) { return@intent @@ -88,8 +152,8 @@ class SendViewModel( } return@intent } - val ticket = session.bubble.getTicket() - val confirmation = session.bubble.getConfirmation() + val ticket = session.ticket()!! + val confirmation = session.confirmation()!! if (ticket.isEmpty()) { reduce { @@ -101,7 +165,7 @@ class SendViewModel( } return@intent } - val copyString = "${session.bubble.getTicket()} ${session.bubble.getConfirmation()}" + val copyString = "${session.ticket()} ${session.confirmation()}" val qrBitmap = generateQRCodeSafely(ticket, confirmation) if (qrBitmap == null) { @@ -226,8 +290,8 @@ class SendViewModel( postSideEffect(SendScreenEffect.NavigateBack) } - private fun listenToSendProgress(session: SendSession) { - session.subscriber.progress.onEach { progress -> + private fun listenToSendProgress(session: ISendSession) { + session.progress.onEach { progress -> intent { val s = state val files = @@ -272,10 +336,10 @@ class SendViewModel( }.launchIn(viewModelScope) } - private fun monitorTransferCompletion(session: SendSession) { + private fun monitorTransferCompletion(session: ISendSession) { viewModelScope.launch { while (coroutineContext.isActive) { - val isFinished = session.bubble.isFinished() + val isFinished = session.isFinished() if (isFinished) { onComplete() break diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a4a836..de54941 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ room = "2.8.1" orbitMVI = "9.0.0" kotlinSerialization = "1.9.0" tripletPlay = "3.10.1" -drop = "17348879247" +drop = "20783386591" devsrsouzaIcons = "1.1.0" coil = "2.5.0" koin = "4.1.1" @@ -86,6 +86,7 @@ kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization- io-koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } io-koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } io-koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +io-koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } io-koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } [plugins]