diff --git a/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.share.components.ShareHeaderTest.shareHeader.png b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.share.components.ShareHeaderTest.shareHeader.png index ac24211e..c04b5c16 100644 Binary files a/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.share.components.ShareHeaderTest.shareHeader.png and b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.share.components.ShareHeaderTest.shareHeader.png differ diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/GravatarFileProvider.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/GravatarFileProvider.kt index c996dfcb..b6701e32 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/GravatarFileProvider.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/GravatarFileProvider.kt @@ -18,5 +18,14 @@ internal class GravatarFileProvider : FileProvider(R.xml.gravatar_filepaths) { file, ) } + + fun getFileUri(context: Context, file: File): Uri { + val authority = "${context.packageName}.fileprovider" + return getUriForFile( + context, + authority, + file, + ) + } } } diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt index c34e1d6c..6160dd41 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt @@ -1,6 +1,7 @@ package com.gravatar.app.homeUi.di import com.gravatar.app.homeUi.ImageDownloader +import com.gravatar.app.homeUi.presentation.DrawableUtils import com.gravatar.app.homeUi.presentation.FileUtils import com.gravatar.app.homeUi.presentation.home.HomeViewModel import com.gravatar.app.homeUi.presentation.home.components.topbar.TopBarPickerPopupViewModel @@ -16,6 +17,7 @@ import org.koin.dsl.module val homeUiModule = module { factory { ImageDownloader(androidContext()) } factoryOf(::FileUtils) + factoryOf(::DrawableUtils) viewModelOf(::ProfileViewModel) viewModelOf(::GravatarViewModel) viewModelOf(::TopBarPickerPopupViewModel) diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/DrawableUtils.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/DrawableUtils.kt new file mode 100644 index 00000000..f8ce1edd --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/DrawableUtils.kt @@ -0,0 +1,55 @@ +package com.gravatar.app.homeUi.presentation + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest +import java.io.ByteArrayOutputStream +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal class DrawableUtils(private val context: Context) { + + private val imageLoader = ImageLoader(context) + + suspend fun downloadDrawable(url: String): Drawable? { + return try { + val request = ImageRequest.Builder(context) + .data(url) + .build() + imageLoader.execute(request).drawable + } catch (_: Exception) { + null + } + } +} + +/** + * Converts a Drawable to a Base64 encoded string. + * + * @param drawable The Drawable to convert. + * @param format The desired image format (Bitmap.CompressFormat.PNG or Bitmap.CompressFormat.JPEG). + * @param quality The quality for JPEG compression (0-100). + * @return The Base64 encoded string representation of the image, or null if conversion fails. + */ +@OptIn(ExperimentalEncodingApi::class) +@Suppress("TooGenericExceptionCaught") +internal fun drawableToBase64( + drawable: Drawable, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + quality: Int = 30 +): Result { + val bitmap = drawable.toBitmap() + + return try { + val base64String = ByteArrayOutputStream().use { outputStream -> + bitmap.compress(format, quality, outputStream) + Base64.encode(outputStream.toByteArray()) + } + Result.success(base64String) + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/FileUtils.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/FileUtils.kt index 07e63ba8..b94fca73 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/FileUtils.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/FileUtils.kt @@ -8,6 +8,17 @@ import java.io.File internal class FileUtils( private val context: Context, ) { + fun createVCardFile(fileNameWithoutExtension: String, content: String): File { + val directory = File(context.cacheDir, "vcard") + directory.mkdirs() + return File( + directory, + "${fileNameWithoutExtension.ifEmpty { "vcard_${System.currentTimeMillis()}" }}.vcf" + ).apply { + writeText(content) + } + } + fun createCroppedAvatarFile(): File { return File(context.cacheDir, "cropped_avatar_${System.currentTimeMillis()}.jpg") } diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareAction.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareAction.kt new file mode 100644 index 00000000..71edbc67 --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareAction.kt @@ -0,0 +1,7 @@ +package com.gravatar.app.homeUi.presentation.home.share + +import java.io.File + +internal sealed class ShareAction { + data class ShareVCard(val vCardFile: File) : ShareAction() +} diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareEvent.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareEvent.kt index dc472791..adb4ef6c 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareEvent.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareEvent.kt @@ -8,4 +8,5 @@ internal sealed class ShareEvent { data object OnDismissAboutAppDialog : ShareEvent() data object OnPrivateInformationClicked : ShareEvent() data object OnDismissPrivateInformationDialog : ShareEvent() + data object OnShareClick : ShareEvent() } diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt index 60304bab..ce2929d9 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt @@ -1,5 +1,7 @@ package com.gravatar.app.homeUi.presentation.home.share +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,13 +12,19 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.gravatar.app.design.theme.GravatarAppTheme +import com.gravatar.app.homeUi.GravatarFileProvider import com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialog import com.gravatar.app.homeUi.presentation.home.share.components.ItemDivider import com.gravatar.app.homeUi.presentation.home.share.components.PrivateInformationDialog @@ -24,7 +32,10 @@ import com.gravatar.app.homeUi.presentation.home.share.components.ShareHeader import com.gravatar.app.homeUi.presentation.home.share.components.SharePrivateContactInfo import com.gravatar.app.homeUi.presentation.home.share.components.SharePublicContactInfo import com.gravatar.extensions.defaultProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel +import java.io.File @Suppress("UnusedParameter") @Composable @@ -33,8 +44,24 @@ internal fun ShareScreen( viewModel: ShareViewModel = koinViewModel(viewModelStoreOwner = viewModelStoreOwner), snackbarHostState: SnackbarHostState ) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current val uiState by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + withContext(Dispatchers.Main.immediate) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.actions.collect { action -> + when (action) { + is ShareAction.ShareVCard -> { + shareVCardFile(action.vCardFile, context) + } + } + } + } + } + } + ShareScreen( uiState = uiState, onEvent = { event -> @@ -57,7 +84,8 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) { onAboutAppClicked = { onEvent(ShareEvent.OnAboutAppClicked) }, - vCardQrCodeData = uiState.vCardQrCodeData.toString(), + vCardQrCodeData = uiState.vCardQrCodeData.exportToString(withPhoto = false), + onShareClick = { onEvent(ShareEvent.OnShareClick) }, modifier = Modifier .fillMaxWidth(), ) @@ -107,6 +135,21 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) { } } +private fun shareVCardFile( + vCardFile: File, + context: Context, +) { + val vCardFileUri = GravatarFileProvider.getFileUri(context, vCardFile) + + val intentShareFile = Intent(Intent.ACTION_SEND) + + // Use the correct MIME type for vCard files + intentShareFile.type = "text/vcard" + intentShareFile.putExtra(Intent.EXTRA_STREAM, vCardFileUri) + + context.startActivity(Intent.createChooser(intentShareFile, null)) +} + @Preview @Composable private fun ShareScreenPreview() { diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt index 5588754f..50a2757c 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt @@ -1,5 +1,6 @@ package com.gravatar.app.homeUi.presentation.home.share +import android.graphics.drawable.Drawable import com.gravatar.app.homeUi.presentation.home.share.model.VCard import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo import com.gravatar.app.usercomponent.domain.model.UserSharePreferences @@ -12,6 +13,7 @@ internal data class ShareUiState( val privateContactInfo: PrivateContactInfo = PrivateContactInfo.Default, val userSharePreferences: UserSharePreferences = UserSharePreferences.Default, val isPrivateInformationDialogVisible: Boolean = false, + private val avatarDrawable: Drawable? = null, ) { val privateContactState = PrivateContactState( emailValue = privateContactInfo.privateEmail, @@ -45,6 +47,7 @@ internal data class ShareUiState( .note(profile?.description.takeIf { userSharePreferences.description }) .phoneNumber(privateContactState.phoneValue.takeIf { privateContactState.isPhoneShared }) .email(privateContactState.emailValue.takeIf { privateContactState.isEmailShared }) + .photo(avatarDrawable) .build() } diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModel.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModel.kt index 4d42ea0c..beae6590 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModel.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModel.kt @@ -2,29 +2,31 @@ package com.gravatar.app.homeUi.presentation.home.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.gravatar.app.homeUi.presentation.DrawableUtils +import com.gravatar.app.homeUi.presentation.FileUtils +import com.gravatar.app.usercomponent.domain.facade.PrivateContactInfoFacade +import com.gravatar.app.usercomponent.domain.facade.UserSharePreferencesFacade import com.gravatar.app.usercomponent.domain.repository.UserRepository import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl -import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo -import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences -import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo -import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal class ShareViewModel( private val userRepository: UserRepository, private val getAvatarUrl: GetAvatarUrl, - private val getUserSharePreferences: GetUserSharePreferences, - private val updateUserSharePreferences: UpdateUserSharePreferences, - private val getPrivateContactInfo: GetPrivateContactInfo, - private val updatePrivateContactInfo: UpdatePrivateContactInfo, + private val sharePreferencesFacade: UserSharePreferencesFacade, + private val privateContactInfoFacade: PrivateContactInfoFacade, + private val drawableUtils: DrawableUtils, + private val fileUtils: FileUtils, ) : ViewModel() { private val _uiState = MutableStateFlow(ShareUiState()) @@ -33,6 +35,9 @@ internal class ShareViewModel( private var saveContactInfoJob: Job? = null private val debounceDelay = 500L // 500ms debounce delay + private val _actions = Channel(Channel.BUFFERED) + val actions = _actions.receiveAsFlow() + init { collectProfile() collectAvatarUrl() @@ -69,6 +74,16 @@ internal class ShareViewModel( is ShareEvent.OnUserSharePreferencesChanged -> handleUserSharePreferencesChange(shareEvent.shareFieldType) is ShareEvent.OnPrivateInformationClicked -> showPrivateInformationDialog() is ShareEvent.OnDismissPrivateInformationDialog -> hidePrivateInformationDialog() + ShareEvent.OnShareClick -> shareVCard() + } + } + + private fun shareVCard() { + viewModelScope.launch { + val vCardContent = uiState.value.vCardQrCodeData.exportToString(withPhoto = true) + val vCardFile = fileUtils.createVCardFile(uiState.value.profile?.displayName.orEmpty(), vCardContent) + + _actions.send(ShareAction.ShareVCard(vCardFile)) } } @@ -78,7 +93,7 @@ internal class ShareViewModel( _uiState.value = this // Save the updated preferences viewModelScope.launch { - updateUserSharePreferences(this@with.userSharePreferences) + sharePreferencesFacade.updatePreferences(this@with.userSharePreferences) } } } @@ -90,7 +105,7 @@ internal class ShareViewModel( // Create a new job with debounce saveContactInfoJob = viewModelScope.launch { delay(debounceDelay) // Wait for the debounce period - updatePrivateContactInfo(_uiState.value.privateContactInfo) + privateContactInfoFacade.updateContactInfo(_uiState.value.privateContactInfo) } } @@ -121,6 +136,7 @@ internal class ShareViewModel( private fun collectAvatarUrl() { getAvatarUrl() .onEach { avatarUrl -> + loadDrawable(avatarUrl?.toString()) _uiState.update { currentState -> currentState.copy( avatarUrl = avatarUrl?.toString(), @@ -130,6 +146,19 @@ internal class ShareViewModel( .launchIn(viewModelScope) } + private fun loadDrawable(avatarUrl: String?) { + viewModelScope.launch { + avatarUrl?.let { + val drawableAvatar = drawableUtils.downloadDrawable(avatarUrl) + _uiState.update { currentState -> + currentState.copy( + avatarDrawable = drawableAvatar, + ) + } + } + } + } + private fun collectProfile() { userRepository.getProfile() .onEach { profile -> @@ -143,7 +172,7 @@ internal class ShareViewModel( } private fun collectUserSharePreferences() { - getUserSharePreferences() + sharePreferencesFacade.getPreferences() .onEach { preferences -> _uiState.update { currentState -> currentState.copy( @@ -155,7 +184,7 @@ internal class ShareViewModel( } private fun collectPrivateContactInfo() { - getPrivateContactInfo() + privateContactInfoFacade.getContactInfo() .onEach { privateContactInfo -> _uiState.update { currentState -> currentState.copy( diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt index 8becf8b0..37c140f1 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt @@ -45,6 +45,7 @@ internal fun ShareHeader( avatarUrl: String, vCardQrCodeData: String, modifier: Modifier = Modifier, + onShareClick: () -> Unit = {}, onAboutAppClicked: () -> Unit = {}, ) { var topBarMenuVisible by remember { mutableStateOf(false) } @@ -91,7 +92,7 @@ internal fun ShareHeader( color = Color.White, ) } - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Box { IconButton( onClick = { @@ -117,6 +118,18 @@ internal fun ShareHeader( ) } } + IconButton( + onClick = { + onShareClick() + }, + modifier = Modifier + .size(MENU_BUTTON_SIZE) + ) { + Image( + painter = painterResource(id = R.drawable.share_button), + contentDescription = null + ) + } } } } @@ -129,6 +142,7 @@ private fun ShareHeaderPreview() { ShareHeader( avatarUrl = "url", vCardQrCodeData = "BEGIN:VCARD\nVERSION:3.0\nFN:Preview User\nEND:VCARD", + onShareClick = { }, modifier = Modifier .fillMaxWidth() ) diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCard.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCard.kt index f9c8ebf2..57e8271d 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCard.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCard.kt @@ -1,5 +1,8 @@ package com.gravatar.app.homeUi.presentation.home.share.model +import android.graphics.drawable.Drawable +import com.gravatar.app.homeUi.presentation.drawableToBase64 + internal class VCard private constructor( val firstName: String? = null, val lastName: String? = null, @@ -10,9 +13,10 @@ internal class VCard private constructor( val note: String? = null, val phoneNumber: String? = null, val email: String? = null, + val photo: Drawable? = null, ) { - override fun toString(): String { + fun exportToString(withPhoto: Boolean = true): String { val contentBuilder = StringBuilder().append("BEGIN:VCARD\n") .append("VERSION:3.0\n") .append("PRODID:Gravatar Android\n") @@ -40,11 +44,20 @@ internal class VCard private constructor( note?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("NOTE:${it.escaped()}\n") } phoneNumber?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("TEL;TYPE=cell:${it.escaped()}\n") } email?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("EMAIL:${it.escaped()}\n") } + if (withPhoto) { + photo?.let { + drawableToBase64(it).onSuccess { photoBase64 -> + contentBuilder.append("PHOTO;ENCODING=b;TYPE=JPEG:$photoBase64\n") + } + } + } contentBuilder.append("END:VCARD") return contentBuilder.toString() } + override fun toString() = exportToString() + // We've seen issues with newlines in the vCard content causing problems when importing the contact so removing them private fun String.escaped() = this.replace("\n", " ") @@ -58,6 +71,7 @@ internal class VCard private constructor( private var note: String? = null, private var phoneNumber: String? = null, private var email: String? = null, + private var photo: Drawable? = null, ) { fun firstName(firstName: String?) = apply { this.firstName = firstName } fun lastName(lastName: String?) = apply { this.lastName = lastName } @@ -68,6 +82,7 @@ internal class VCard private constructor( fun note(description: String?) = apply { this.note = description } fun phoneNumber(phone: String?) = apply { this.phoneNumber = phone } fun email(email: String?) = apply { this.email = email } + fun photo(photo: Drawable?) = apply { this.photo = photo } fun build() = VCard( firstName = firstName, @@ -78,7 +93,8 @@ internal class VCard private constructor( profileUrl = profileUrl, note = note, phoneNumber = phoneNumber, - email = email + email = email, + photo = photo, ) } } diff --git a/homeUi/src/main/res/drawable/share_button.xml b/homeUi/src/main/res/drawable/share_button.xml new file mode 100644 index 00000000..2d16992d --- /dev/null +++ b/homeUi/src/main/res/drawable/share_button.xml @@ -0,0 +1,13 @@ + + + + diff --git a/homeUi/src/main/res/xml/gravatar_filepaths.xml b/homeUi/src/main/res/xml/gravatar_filepaths.xml index d3e5e1e2..8588aac7 100644 --- a/homeUi/src/main/res/xml/gravatar_filepaths.xml +++ b/homeUi/src/main/res/xml/gravatar_filepaths.xml @@ -3,4 +3,7 @@ + diff --git a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt index 6102ff8e..ad2f48f7 100644 --- a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt +++ b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt @@ -1,7 +1,11 @@ package com.gravatar.app.homeUi.presentation.home.share import app.cash.turbine.test +import com.gravatar.app.homeUi.presentation.DrawableUtils +import com.gravatar.app.homeUi.presentation.FileUtils import com.gravatar.app.testUtils.CoroutineTestRule +import com.gravatar.app.usercomponent.domain.facade.PrivateContactInfoFacade +import com.gravatar.app.usercomponent.domain.facade.UserSharePreferencesFacade import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo import com.gravatar.app.usercomponent.domain.model.UserSharePreferences import com.gravatar.app.usercomponent.domain.repository.UserRepository @@ -12,8 +16,10 @@ import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences import com.gravatar.restapi.models.Profile import com.gravatar.restapi.models.ProfileContactInfo +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -26,6 +32,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test +import java.io.File import java.net.URI import java.net.URL @@ -55,7 +62,22 @@ class ShareViewModelTest { privateContactInfoFlow.emit(privateContactInfo) } } + + private val userSharePreferencesFacade = object : UserSharePreferencesFacade { + override fun getPreferences() = getUserSharePreferences() + override suspend fun updatePreferences(preferences: UserSharePreferences) = + updateUserSharePreferences(preferences) + } + + private val privateContactInfoFacade = object : PrivateContactInfoFacade { + override fun getContactInfo() = getPrivateContactInfo() + override suspend fun updateContactInfo(info: PrivateContactInfo) = + updatePrivateContactInfo(info) + } private val userRepository = mockk() + private val fileUtils = mockk() + private val drawableUtils = mockk() + private val testVCardFile = mockk() private lateinit var viewModel: ShareViewModel @@ -67,13 +89,16 @@ class ShareViewModelTest { @Before fun setup() { every { userRepository.getProfile() } returns profileFlow + coEvery { fileUtils.createVCardFile(any(), any()) } returns testVCardFile + coEvery { drawableUtils.downloadDrawable(any()) } returns null + viewModel = ShareViewModel( userRepository, getAvatarUrl, - getUserSharePreferences, - updateUserSharePreferences, - getPrivateContactInfo, - updatePrivateContactInfo + userSharePreferencesFacade, + privateContactInfoFacade, + drawableUtils, + fileUtils, ) } @@ -514,4 +539,26 @@ class ShareViewModelTest { email = "test@example.com" } } + + @Test + fun `when OnShareClick event is triggered then ShareVCard action is sent`() = runTest { + // Given + val testProfile = createTestProfile() + profileFlow.emit(testProfile) + advanceUntilIdle() + + // When + viewModel.actions.test { + viewModel.onEvent(ShareEvent.OnShareClick) + advanceUntilIdle() + + // Then + val action = awaitItem() + assertTrue(action is ShareAction.ShareVCard) + assertEquals(testVCardFile, (action as ShareAction.ShareVCard).vCardFile) + + // Verify that createVCardFile was called with the correct parameters + verify { fileUtils.createVCardFile(eq(testProfile.displayName), any()) } + } + } } diff --git a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCardTest.kt b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCardTest.kt index 06d5de93..4d32a7de 100644 --- a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCardTest.kt +++ b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/model/VCardTest.kt @@ -1,11 +1,35 @@ package com.gravatar.app.homeUi.presentation.home.share.model +import android.graphics.drawable.Drawable +import com.gravatar.app.homeUi.presentation.drawableToBase64 +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test class VCardTest { + private lateinit var mockDrawable: Drawable + private val testBase64String = "TestBase64EncodedPhotoData" + + @Before + fun setup() { + mockDrawable = mockk() + mockkStatic(::drawableToBase64) + every { drawableToBase64(mockDrawable, any(), any()) } returns Result.success(testBase64String) + } + + @After + fun tearDown() { + unmockkStatic(::drawableToBase64) + } + @Test fun `builder creates VCard with all fields correctly`() { val vCard = VCard.Builder() @@ -174,4 +198,52 @@ class VCardTest { assertTrue(vCardString.contains("EMAIL:john.doe@example.com")) assertTrue(vCardString.endsWith("END:VCARD")) } + + @Test + fun `when photo is available and withPhoto is true, photo is included in vCard`() { + // Given + val vCard = VCard.Builder() + .firstName("John") + .lastName("Doe") + .photo(mockDrawable) + .build() + + // When + val vCardString = vCard.exportToString(withPhoto = true) + + // Then + assertTrue(vCardString.contains("PHOTO;ENCODING=b;TYPE=JPEG:$testBase64String")) + } + + @Test + fun `when photo is available and withPhoto is false, photo is not included in vCard`() { + // Given + val vCard = VCard.Builder() + .firstName("John") + .lastName("Doe") + .photo(mockDrawable) + .build() + + // When + val vCardString = vCard.exportToString(withPhoto = false) + + // Then + assertFalse(vCardString.contains("PHOTO;ENCODING=b;TYPE=JPEG:")) + } + + @Test + fun `when photo is null and withPhoto is true, photo is not included in vCard`() { + // Given + val vCard = VCard.Builder() + .firstName("John") + .lastName("Doe") + .photo(null) + .build() + + // When + val vCardString = vCard.exportToString(withPhoto = true) + + // Then + assertFalse(vCardString.contains("PHOTO;ENCODING=b;TYPE=JPEG:")) + } } diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt index d630dedb..3fa2bba9 100644 --- a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt @@ -6,6 +6,10 @@ import com.gravatar.app.usercomponent.data.RealProfileRepository import com.gravatar.app.usercomponent.data.RealUserRepository import com.gravatar.app.usercomponent.data.UserSessionPersistence import com.gravatar.app.usercomponent.data.WordPressClient +import com.gravatar.app.usercomponent.domain.facade.PrivateContactInfoFacade +import com.gravatar.app.usercomponent.domain.facade.PrivateContactInfoOperations +import com.gravatar.app.usercomponent.domain.facade.UserSharePreferencesFacade +import com.gravatar.app.usercomponent.domain.facade.UserSharePreferencesOperations import com.gravatar.app.usercomponent.domain.repository.AuthRepository import com.gravatar.app.usercomponent.domain.repository.ProfileRepository import com.gravatar.app.usercomponent.domain.repository.UserRepository @@ -54,6 +58,8 @@ val userComponentModule = module { factoryOf(::UpdateUserSharePreferencesUseCase) { bind() } factoryOf(::GetPrivateContactInfoUseCase) { bind() } factoryOf(::UpdatePrivateContactInfoUseCase) { bind() } + factoryOf(::UserSharePreferencesOperations) { bind() } + factoryOf(::PrivateContactInfoOperations) { bind() } factoryOf(::WordPressClient) singleOf(::InMemoryUserSessionPersistence) { bind() } includes(httpClientModule) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/PrivateContactInfoOperations.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/PrivateContactInfoOperations.kt new file mode 100644 index 00000000..b855ab7f --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/PrivateContactInfoOperations.kt @@ -0,0 +1,40 @@ +package com.gravatar.app.usercomponent.domain.facade + +import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo +import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo +import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo +import kotlinx.coroutines.flow.Flow + +/** + * Facade implementation that combines contact information-related use cases. + */ +internal class PrivateContactInfoOperations( + private val getPrivateContactInfo: GetPrivateContactInfo, + private val updatePrivateContactInfo: UpdatePrivateContactInfo +) : PrivateContactInfoFacade { + /** + * Get private contact information as a Flow. + */ + override fun getContactInfo(): Flow = getPrivateContactInfo() + + /** + * Update private contact information. + */ + override suspend fun updateContactInfo(info: PrivateContactInfo) = + updatePrivateContactInfo(info) +} + +/** + * Facade that combines contact information-related use cases. + */ +interface PrivateContactInfoFacade { + /** + * Get private contact information as a Flow. + */ + fun getContactInfo(): Flow + + /** + * Update private contact information. + */ + suspend fun updateContactInfo(info: PrivateContactInfo) +} diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/UserSharePreferencesOperations.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/UserSharePreferencesOperations.kt new file mode 100644 index 00000000..7c632b98 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/facade/UserSharePreferencesOperations.kt @@ -0,0 +1,40 @@ +package com.gravatar.app.usercomponent.domain.facade + +import com.gravatar.app.usercomponent.domain.model.UserSharePreferences +import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences +import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences +import kotlinx.coroutines.flow.Flow + +/** + * Facade implementation that combines user preferences-related use cases. + */ +internal class UserSharePreferencesOperations( + private val getUserSharePreferences: GetUserSharePreferences, + private val updateUserSharePreferences: UpdateUserSharePreferences +) : UserSharePreferencesFacade { + /** + * Get user share preferences as a Flow. + */ + override fun getPreferences(): Flow = getUserSharePreferences() + + /** + * Update user share preferences. + */ + override suspend fun updatePreferences(preferences: UserSharePreferences) = + updateUserSharePreferences(preferences) +} + +/** + * Facade that combines user preferences-related use cases. + */ +interface UserSharePreferencesFacade { + /** + * Get user share preferences as a Flow. + */ + fun getPreferences(): Flow + + /** + * Update user share preferences. + */ + suspend fun updatePreferences(preferences: UserSharePreferences) +}