Skip to content

Commit 0254e9e

Browse files
authored
Merge pull request #20886 from wordpress-mobile/issue/89-basic-media-recorder
Voice to Content: basic media recorder implementation
2 parents f8db3db + 29cc555 commit 0254e9e

File tree

15 files changed

+436
-77
lines changed

15 files changed

+436
-77
lines changed

WordPress/src/debug/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
android:name="android.permission.DUMP"
2727
tools:ignore="ProtectedPermissions" />
2828

29+
<!-- Adds this permission temporarily here until Voice to content project is released -->
30+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
31+
2932
<application
3033
android:name=".WordPressDebug"
3134
android:supportsRtl="true"

WordPress/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<uses-permission android:name="android.permission.CAMERA" />
1818
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
1919
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
20+
2021
<!-- Required for storing and retrieving screenshots, taking photos, accessing media files -->
2122
<uses-permission
2223
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import org.wordpress.android.ui.sitecreation.SiteCreationStep;
2929
import org.wordpress.android.ui.sitecreation.SiteCreationStepsProvider;
3030
import org.wordpress.android.util.BuildConfigWrapper;
31+
import org.wordpress.android.util.audio.AudioRecorder;
32+
import org.wordpress.android.util.audio.IAudioRecorder;
3133
import org.wordpress.android.util.config.InAppUpdatesFeatureConfig;
3234
import org.wordpress.android.util.config.RemoteConfigWrapper;
3335
import org.wordpress.android.util.wizard.WizardManager;
@@ -121,4 +123,9 @@ public static ActivityNavigator provideActivityNavigator(@ApplicationContext Con
121123
public static SensorManager provideSensorManager(@ApplicationContext Context context) {
122124
return (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
123125
}
126+
127+
@Provides
128+
public static IAudioRecorder provideAudioRecorder(@ApplicationContext Context context) {
129+
return new AudioRecorder(context);
130+
}
124131
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.wordpress.android.ui.voicetocontent
2+
3+
import kotlinx.coroutines.flow.Flow
4+
import org.wordpress.android.util.audio.IAudioRecorder
5+
import org.wordpress.android.util.audio.RecordingUpdate
6+
import javax.inject.Inject
7+
8+
class RecordingUseCase @Inject constructor(
9+
private val audioRecorder: IAudioRecorder
10+
) {
11+
fun startRecording(onRecordingFinished: (String) -> Unit) {
12+
audioRecorder.startRecording(onRecordingFinished)
13+
}
14+
15+
@Suppress("ReturnCount")
16+
fun stopRecording() {
17+
audioRecorder.stopRecording()
18+
}
19+
20+
fun recordingUpdates(): Flow<RecordingUpdate> {
21+
return audioRecorder.recordingUpdates()
22+
}
23+
}
24+

WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package org.wordpress.android.ui.voicetocontent
22

3+
import android.content.Intent
4+
import android.content.pm.PackageManager
5+
import android.net.Uri
36
import android.os.Bundle
47
import android.view.LayoutInflater
58
import android.view.View
69
import android.view.ViewGroup
10+
import androidx.activity.result.contract.ActivityResultContracts
11+
import androidx.appcompat.app.AlertDialog
712
import androidx.compose.foundation.clickable
813
import androidx.compose.runtime.Composable
914
import androidx.compose.runtime.getValue
@@ -27,7 +32,10 @@ import androidx.compose.ui.res.painterResource
2732
import androidx.compose.ui.text.font.FontWeight
2833
import androidx.compose.ui.unit.dp
2934
import androidx.compose.ui.unit.sp
35+
import androidx.core.content.ContextCompat
3036
import org.wordpress.android.R
37+
import org.wordpress.android.util.audio.IAudioRecorder.Companion.REQUIRED_RECORDING_PERMISSIONS
38+
import android.provider.Settings
3139

3240
@AndroidEntryPoint
3341
class VoiceToContentDialogFragment : BottomSheetDialogFragment() {
@@ -38,11 +46,57 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() {
3846
): View = ComposeView(requireContext()).apply {
3947
setContent {
4048
AppTheme {
41-
VoiceToContentScreen(viewModel)
49+
VoiceToContentScreen(
50+
viewModel = viewModel,
51+
onRequestPermission = { requestAllPermissionsForRecording() },
52+
hasPermission = { hasAllPermissionsForRecording() }
53+
)
54+
}
55+
}
56+
}
57+
58+
private val requestMultiplePermissionsLauncher = registerForActivityResult(
59+
ActivityResultContracts.RequestMultiplePermissions()
60+
) { permissions ->
61+
val areAllPermissionsGranted = permissions.entries.all { it.value }
62+
if (areAllPermissionsGranted) {
63+
viewModel.startRecording()
64+
} else {
65+
// Check if any permissions were denied permanently
66+
if (permissions.entries.any { !it.value }) {
67+
showPermissionDeniedDialog()
4268
}
4369
}
4470
}
4571

72+
private fun hasAllPermissionsForRecording(): Boolean {
73+
return REQUIRED_RECORDING_PERMISSIONS.all {
74+
ContextCompat.checkSelfPermission(
75+
requireContext(),
76+
it
77+
) == PackageManager.PERMISSION_GRANTED
78+
}
79+
}
80+
81+
private fun requestAllPermissionsForRecording() {
82+
requestMultiplePermissionsLauncher.launch(REQUIRED_RECORDING_PERMISSIONS)
83+
}
84+
85+
private fun showPermissionDeniedDialog() {
86+
AlertDialog.Builder(requireContext())
87+
.setTitle(R.string.voice_to_content_permissions_required_title)
88+
.setMessage(R.string.voice_to_content_permissions_required_msg)
89+
.setPositiveButton("Settings") { _, _ ->
90+
// Open the app's settings
91+
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
92+
data = Uri.fromParts("package", requireContext().packageName, null)
93+
}
94+
startActivity(intent)
95+
}
96+
.setNegativeButton("Cancel", null)
97+
.show()
98+
}
99+
46100
companion object {
47101
const val TAG = "voice_to_content_fragment_tag"
48102

@@ -52,7 +106,11 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() {
52106
}
53107

54108
@Composable
55-
fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) {
109+
fun VoiceToContentScreen(
110+
viewModel: VoiceToContentViewModel,
111+
onRequestPermission: () -> Unit,
112+
hasPermission: () -> Boolean
113+
) {
56114
val result by viewModel.uiState.observeAsState()
57115
val assistantFeature by viewModel.aiAssistantFeatureState.observeAsState()
58116
Column(
@@ -83,7 +141,24 @@ fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) {
83141
contentDescription = "Microphone",
84142
modifier = Modifier
85143
.size(64.dp)
86-
.clickable { viewModel.execute() }
144+
.clickable {
145+
if (hasPermission()) {
146+
viewModel.startRecording()
147+
} else {
148+
onRequestPermission()
149+
}
150+
}
151+
)
152+
153+
Spacer(modifier = Modifier.height(16.dp))
154+
Icon(
155+
painterResource(id = com.google.android.exoplayer2.ui.R.drawable.exo_icon_stop),
156+
contentDescription = "Stop",
157+
modifier = Modifier
158+
.size(64.dp)
159+
.clickable {
160+
viewModel.stopRecording()
161+
}
87162
)
88163
}
89164
}

WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtils.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ class VoiceToContentFeatureUtils @Inject constructor(
99
private val buildConfigWrapper: BuildConfigWrapper,
1010
private val voiceToContentFeatureConfig: VoiceToContentFeatureConfig
1111
) {
12-
fun isVoiceToContentEnabled() = buildConfigWrapper.isJetpackApp && voiceToContentFeatureConfig.isEnabled()
12+
// todo: remove buildConfigWrapper.isDebug() when Voice to content is ready for release
13+
fun isVoiceToContentEnabled() = buildConfigWrapper.isJetpackApp
14+
&& voiceToContentFeatureConfig.isEnabled()
15+
&& buildConfigWrapper.isDebug()
1316

1417
fun isEligibleForVoiceToContent(jetpackFeatureAIAssistantFeature: JetpackAIAssistantFeature) =
1518
!jetpackFeatureAIAssistantFeature.siteRequireUpgrade
Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
package org.wordpress.android.ui.voicetocontent
22

3-
import android.content.Context
43
import kotlinx.coroutines.Dispatchers
54
import kotlinx.coroutines.withContext
65
import org.wordpress.android.fluxc.model.SiteModel
76
import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIQueryResponse
87
import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionResponse
98
import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore
10-
import org.wordpress.android.viewmodel.ContextProvider
119
import java.io.File
12-
import java.io.FileOutputStream
13-
import java.io.InputStream
1410
import javax.inject.Inject
1511

1612
class VoiceToContentUseCase @Inject constructor(
17-
private val jetpackAIStore: JetpackAIStore,
18-
private val fileHelperWrapper: VoiceToContentTempFileHelperWrapper
13+
private val jetpackAIStore: JetpackAIStore
1914
) {
2015
companion object {
2116
const val FEATURE = "voice_to_content"
@@ -26,9 +21,9 @@ class VoiceToContentUseCase @Inject constructor(
2621

2722
suspend fun execute(
2823
siteModel: SiteModel,
24+
file: File
2925
): VoiceToContentResult =
3026
withContext(Dispatchers.IO) {
31-
val file = fileHelperWrapper.getAudioFile() ?: return@withContext VoiceToContentResult(isError = true)
3227
val transcriptionResponse = jetpackAIStore.fetchJetpackAITranscription(
3328
siteModel,
3429
FEATURE,
@@ -79,43 +74,3 @@ data class VoiceToContentResult(
7974
val content: String? = null,
8075
val isError: Boolean = false
8176
)
82-
83-
// todo: Remove this class when real impl is in place - it's here so I can start unit tests
84-
class VoiceToContentTempFileHelperWrapper @Inject constructor(
85-
private val contextProvider: ContextProvider
86-
) {
87-
fun getAudioFile(): File? {
88-
val result = runCatching {
89-
getFileFromAssets(contextProvider.getContext())
90-
}
91-
92-
return result.getOrElse {
93-
null
94-
}
95-
}
96-
97-
// todo: Do not forget to delete the test file from the asset directory
98-
private fun getFileFromAssets(context: Context): File {
99-
val fileName = "jetpack-ai-transcription-test-audio-file.m4a"
100-
val file = File(context.filesDir, fileName)
101-
context.assets.open(fileName).use { inputStream ->
102-
copyInputStreamToFile(inputStream, file)
103-
}
104-
return file
105-
}
106-
107-
private fun copyInputStreamToFile(inputStream: InputStream, outputFile: File) {
108-
FileOutputStream(outputFile).use { outputStream ->
109-
val buffer = ByteArray(KILO_BYTE)
110-
var length: Int
111-
while (inputStream.read(buffer).also { length = it } > 0) {
112-
outputStream.write(buffer, 0, length)
113-
}
114-
outputStream.flush()
115-
}
116-
inputStream.close()
117-
}
118-
companion object {
119-
const val KILO_BYTE = 1024
120-
}
121-
}
Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package org.wordpress.android.ui.voicetocontent
22

3+
import android.util.Log
34
import androidx.lifecycle.LiveData
45
import androidx.lifecycle.MutableLiveData
56
import androidx.lifecycle.viewModelScope
67
import dagger.hilt.android.lifecycle.HiltViewModel
78
import kotlinx.coroutines.CoroutineDispatcher
89
import kotlinx.coroutines.Dispatchers
910
import kotlinx.coroutines.launch
11+
import org.wordpress.android.fluxc.model.SiteModel
1012
import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature
1113
import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIAssistantFeatureResponse
1214
import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore
1315
import org.wordpress.android.modules.UI_THREAD
1416
import org.wordpress.android.ui.mysite.SelectedSiteRepository
1517
import org.wordpress.android.viewmodel.ScopedViewModel
18+
import java.io.File
1619
import javax.inject.Inject
1720
import javax.inject.Named
1821

@@ -22,7 +25,8 @@ class VoiceToContentViewModel @Inject constructor(
2225
private val voiceToContentFeatureUtils: VoiceToContentFeatureUtils,
2326
private val voiceToContentUseCase: VoiceToContentUseCase,
2427
private val selectedSiteRepository: SelectedSiteRepository,
25-
private val jetpackAIStore: JetpackAIStore
28+
private val jetpackAIStore: JetpackAIStore,
29+
private val recordingUseCase: RecordingUseCase
2630
) : ScopedViewModel(mainDispatcher) {
2731
private val _uiState = MutableLiveData<VoiceToContentResult>()
2832
val uiState = _uiState as LiveData<VoiceToContentResult>
@@ -32,7 +36,48 @@ class VoiceToContentViewModel @Inject constructor(
3236

3337
private fun isVoiceToContentEnabled() = voiceToContentFeatureUtils.isVoiceToContentEnabled()
3438

35-
fun execute() {
39+
init {
40+
observeRecordingUpdates()
41+
}
42+
43+
private fun observeRecordingUpdates() {
44+
viewModelScope.launch {
45+
recordingUseCase.recordingUpdates().collect { update ->
46+
if (update.fileSizeLimitExceeded) {
47+
stopRecording()
48+
} else {
49+
// todo: Handle other updates if needed when UI is ready, e.g., elapsed time and file size
50+
Log.d("AudioRecorder", "Recording update: $update")
51+
}
52+
}
53+
}
54+
}
55+
56+
fun startRecording() {
57+
recordingUseCase.startRecording { recordingPath ->
58+
val file = getRecordingFile(recordingPath)
59+
file?.let {
60+
executeVoiceToContent(it)
61+
} ?: run {
62+
_uiState.postValue(VoiceToContentResult(isError = true))
63+
}
64+
}
65+
}
66+
67+
@Suppress("ReturnCount")
68+
private fun getRecordingFile(recordingPath: String): File? {
69+
if (recordingPath.isEmpty()) return null
70+
val recordingFile = File(recordingPath)
71+
// Return null if the file does not exist, is not a file, or is empty
72+
if (!recordingFile.exists() || !recordingFile.isFile || recordingFile.length() == 0L) return null
73+
return recordingFile
74+
}
75+
76+
fun stopRecording() {
77+
recordingUseCase.stopRecording()
78+
}
79+
80+
fun executeVoiceToContent(file: File) {
3681
val site = selectedSiteRepository.getSelectedSite() ?: run {
3782
_uiState.postValue(VoiceToContentResult(isError = true))
3883
return
@@ -44,7 +89,7 @@ class VoiceToContentViewModel @Inject constructor(
4489
when (result) {
4590
is JetpackAIAssistantFeatureResponse.Success -> {
4691
_aiAssistantFeatureState.postValue(result.model)
47-
startVoiceToContentFlow()
92+
startVoiceToContentFlow(site, file)
4893
}
4994
is JetpackAIAssistantFeatureResponse.Error -> {
5095
_uiState.postValue(VoiceToContentResult(isError = true))
@@ -54,17 +99,13 @@ class VoiceToContentViewModel @Inject constructor(
5499
}
55100
}
56101

57-
private fun startVoiceToContentFlow() {
58-
val site = selectedSiteRepository.getSelectedSite() ?: run {
59-
_uiState.postValue(VoiceToContentResult(isError = true))
60-
return
61-
}
62-
102+
private fun startVoiceToContentFlow(site: SiteModel, file: File) {
63103
if (isVoiceToContentEnabled()) {
64104
viewModelScope.launch {
65-
val result = voiceToContentUseCase.execute(site)
105+
val result = voiceToContentUseCase.execute(site, file)
66106
_uiState.postValue(result)
67107
}
68108
}
69109
}
70110
}
111+

0 commit comments

Comments
 (0)