Skip to content

Commit

Permalink
Merge pull request #20871 from wordpress-mobile/issue/voice-to-conten…
Browse files Browse the repository at this point in the history
…t-frag-vm-skeleton

[Voice to Content] Implement frag/view model skeleton
  • Loading branch information
zwarm committed May 24, 2024
2 parents dc4e5bf + f7baf1e commit b61a413
Show file tree
Hide file tree
Showing 14 changed files with 407 additions and 2 deletions.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ sealed class MainActionListItem {
CREATE_NEW_PAGE,
CREATE_NEW_PAGE_FROM_PAGES_CARD,
CREATE_NEW_POST,
ANSWER_BLOGGING_PROMPT
ANSWER_BLOGGING_PROMPT,
CREATE_NEW_POST_FROM_AUDIO_AI
}

data class CreateAction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
import org.wordpress.android.ui.uploads.UploadUtilsWrapper;
import org.wordpress.android.ui.utils.JetpackAppMigrationFlowUtils;
import org.wordpress.android.ui.utils.UiString.UiStringRes;
import org.wordpress.android.ui.voicetocontent.VoiceToContentDialogFragment;
import org.wordpress.android.ui.whatsnew.FeatureAnnouncementDialogFragment;
import org.wordpress.android.util.AniUtils;
import org.wordpress.android.util.AppLog;
Expand Down Expand Up @@ -719,6 +720,9 @@ private void initViewModel() {

mViewModel.getCreateAction().observe(this, createAction -> {
switch (createAction) {
case CREATE_NEW_POST_FROM_AUDIO_AI:
launchVoiceToContent();
break;
case CREATE_NEW_POST:
handleNewPostAction(PagePostCreationSourcesDetail.POST_FROM_MY_SITE, -1, null);
break;
Expand Down Expand Up @@ -1325,6 +1329,15 @@ private void handleNewPostAction(PagePostCreationSourcesDetail source,
ActivityLauncher.addNewPostForResult(this, getSelectedSite(), false, source, promptId, entryPoint);
}

private void launchVoiceToContent() {
if (!mSiteStore.hasSite()) {
// No site yet - Move to My Sites fragment that shows the create new site screen
mBottomNav.setCurrentSelectedPage(PageType.MY_SITE);
return;
}
VoiceToContentDialogFragment.newInstance().show(getSupportFragmentManager(), VoiceToContentDialogFragment.TAG);
}

private void trackLastVisiblePage(@NonNull final PageType pageType) {
switch (pageType) {
case MY_SITE:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.wordpress.android.ui.voicetocontent

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.viewModels
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import org.wordpress.android.ui.compose.theme.AppTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.wordpress.android.R

@AndroidEntryPoint
class VoiceToContentDialogFragment : BottomSheetDialogFragment() {
private val viewModel: VoiceToContentViewModel by viewModels()

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View = ComposeView(requireContext()).apply {
setContent {
AppTheme {
VoiceToContentScreen(viewModel)
}
}
}

companion object {
const val TAG = "voice_to_content_fragment_tag"

@JvmStatic
fun newInstance() = VoiceToContentDialogFragment()
}
}

@Composable
fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) {
val result by viewModel.uiState.observeAsState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
when {
result?.isError == true -> {
Text(text = "Error happened", fontSize = 20.sp, fontWeight = FontWeight.Bold)
}

result?.content != null -> {
Text(text = result?.content!!, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}

else -> {
Text(text = "Ready to fake record - tap microphone", fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
Icon(
painterResource(id = R.drawable.ic_mic_white_24dp),
contentDescription = "Microphone",
modifier = Modifier
.size(64.dp)
.clickable { viewModel.execute() }
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.wordpress.android.ui.voicetocontent

import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.util.config.VoiceToContentFeatureConfig
import javax.inject.Inject

class VoiceToContentFeatureUtils @Inject constructor(
private val buildConfigWrapper: BuildConfigWrapper,
private val voiceToContentFeatureConfig: VoiceToContentFeatureConfig
) {
fun isVoiceToContentEnabled() = buildConfigWrapper.isJetpackApp && voiceToContentFeatureConfig.isEnabled()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.wordpress.android.ui.voicetocontent

import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionRestClient
import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore
import org.wordpress.android.viewmodel.ContextProvider
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import javax.inject.Inject

class VoiceToContentUseCase @Inject constructor(
private val jetpackAIStore: JetpackAIStore,
private val contextProvider: ContextProvider
) {
companion object {
const val FEATURE = "voice_to_content"
private const val KILO_BYTE = 1024
}

suspend fun execute(
siteModel: SiteModel,
): VoiceToContentResult =
withContext(Dispatchers.IO) {
val file = getAudioFile() ?: return@withContext VoiceToContentResult(isError = true)
val response = jetpackAIStore.fetchJetpackAITranscription(
siteModel,
FEATURE,
file
)

when(response) {
is JetpackAITranscriptionRestClient.JetpackAITranscriptionResponse.Success -> {
return@withContext VoiceToContentResult(content = response.model)
}
is JetpackAITranscriptionRestClient.JetpackAITranscriptionResponse.Error -> {
return@withContext VoiceToContentResult(isError = true)
}
}
}

// todo: The next three methods are temporary to support development - remove when the real impl is in place
private fun getAudioFile(): File? {
val result = runCatching {
getFileFromAssets(contextProvider.getContext())
}

return result.getOrElse {
null
}
}

// todo: Do not forget to delete the test file from the asset directory - when the real impl is in place
private fun getFileFromAssets(context: Context): File {
val fileName = "jetpack-ai-transcription-test-audio-file.m4a"
val file = File(context.filesDir, fileName)
context.assets.open(fileName).use { inputStream ->
copyInputStreamToFile(inputStream, file)
}
return file
}

private fun copyInputStreamToFile(inputStream: InputStream, outputFile: File) {
FileOutputStream(outputFile).use { outputStream ->
val buffer = ByteArray(KILO_BYTE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
outputStream.write(buffer, 0, length)
}
outputStream.flush()
}
inputStream.close()
}
}

// todo: build out the result object
data class VoiceToContentResult(
val content: String? = null,
val isError: Boolean = false
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.wordpress.android.ui.voicetocontent

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import org.wordpress.android.modules.UI_THREAD
import org.wordpress.android.ui.mysite.SelectedSiteRepository
import org.wordpress.android.viewmodel.ScopedViewModel
import javax.inject.Inject
import javax.inject.Named

@HiltViewModel
class VoiceToContentViewModel @Inject constructor(
@Named(UI_THREAD) mainDispatcher: CoroutineDispatcher,
private val voiceToContentFeatureUtils: VoiceToContentFeatureUtils,
private val voiceToContentUseCase: VoiceToContentUseCase,
private val selectedSiteRepository: SelectedSiteRepository
) : ScopedViewModel(mainDispatcher) {
private val _uiState = MutableLiveData<VoiceToContentResult>()
val uiState = _uiState as LiveData<VoiceToContentResult>

private fun isVoiceToContentEnabled() = voiceToContentFeatureUtils.isVoiceToContentEnabled()

fun execute() {
val site = selectedSiteRepository.getSelectedSite() ?: run {
_uiState.postValue(VoiceToContentResult(isError = true))
return
}

if (isVoiceToContentEnabled()) {
viewModelScope.launch {
val result = voiceToContentUseCase.execute(site)
_uiState.postValue(result)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository
import org.wordpress.android.ui.prefs.AppPrefsWrapper
import org.wordpress.android.ui.prefs.privacy.banner.domain.ShouldAskPrivacyConsent
import org.wordpress.android.ui.utils.UiString.UiStringText
import org.wordpress.android.ui.voicetocontent.VoiceToContentFeatureUtils
import org.wordpress.android.ui.whatsnew.FeatureAnnouncementProvider
import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.util.FluxCUtils
Expand Down Expand Up @@ -68,6 +69,7 @@ class WPMainActivityViewModel @Inject constructor(
private val bloggingPromptsStore: BloggingPromptsStore,
@Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher,
private val shouldAskPrivacyConsent: ShouldAskPrivacyConsent,
private val voiceToContentFeatureUtils: VoiceToContentFeatureUtils
) : ScopedViewModel(mainDispatcher) {
private var isStarted = false

Expand Down Expand Up @@ -203,6 +205,16 @@ class WPMainActivityViewModel @Inject constructor(
onClickAction = ::onCreateActionClicked
)
)
if (voiceToContentFeatureUtils.isVoiceToContentEnabled() && hasFullAccessToContent(site)) {
actionsList.add(
CreateAction(
actionType = ActionType.CREATE_NEW_POST_FROM_AUDIO_AI,
iconRes = R.drawable.ic_mic_white_24dp,
labelRes = R.string.my_site_bottom_sheet_add_post_from_audio,
onClickAction = ::onCreateActionClicked
)
)
}
if (hasFullAccessToContent(site)) {
actionsList.add(
CreateAction(
Expand Down
5 changes: 5 additions & 0 deletions WordPress/src/main/res/drawable/ic_mic_white_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">

<path android:fillColor="@android:color/white" android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>

</vector>
1 change: 1 addition & 0 deletions WordPress/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,7 @@
<string name="my_site_bottom_sheet_add_page">Site page</string>
<string name="my_site_bottom_sheet_add_story">Story post</string>
<string name="my_site_quick_actions_title">Quick Links</string>
<string name="my_site_bottom_sheet_add_post_from_audio">Post from audio</string>

<string name="jetpack_feature_card_more_menu_item_hide_this" translatable="false">@string/my_site_dashboard_card_more_menu_hide_card</string>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package org.wordpress.android.ui.voicetocontent

import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.whenever
import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.util.config.VoiceToContentFeatureConfig

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class VoiceToContentFeatureUtilsTest {
@Mock
lateinit var buildConfigWrapper: BuildConfigWrapper

@Mock
lateinit var voiceToContentFeatureConfig: VoiceToContentFeatureConfig

private lateinit var utils: VoiceToContentFeatureUtils

@Before
fun setup() {
utils = VoiceToContentFeatureUtils(buildConfigWrapper, voiceToContentFeatureConfig)
}

@Test
fun `when buildConfigWrapper and featureConfig are enabled then returns true`() {
// Arrange
whenever(buildConfigWrapper.isJetpackApp).thenReturn(true)
whenever(voiceToContentFeatureConfig.isEnabled()).thenReturn(true)

// Act
val result = utils.isVoiceToContentEnabled()

// Assert
assertEquals(true, result)
}

@Test
fun `when buildConfigWrapper is disabled then returns false `() {
// Arrange
whenever(buildConfigWrapper.isJetpackApp).thenReturn(false)

// Act
val result = utils.isVoiceToContentEnabled()

// Assert
assertEquals(false, result)
}

@Test
fun `when voiceToContentFeatureConfig is disabled then returns false `() {
// Arrange
whenever(buildConfigWrapper.isJetpackApp).thenReturn(true)
whenever(voiceToContentFeatureConfig.isEnabled()).thenReturn(false)

// Act
val result = utils.isVoiceToContentEnabled()

// Assert
assertEquals(false, result)
}
}
Loading

0 comments on commit b61a413

Please sign in to comment.