diff --git a/.github/workflows/run-danger.yml b/.github/workflows/run-danger.yml index 1031d64a7dd9..ec845c47c73a 100644 --- a/.github/workflows/run-danger.yml +++ b/.github/workflows/run-danger.yml @@ -7,7 +7,7 @@ on: jobs: dangermattic: if: ${{ (github.event.pull_request.draft == false) }} - uses: Automattic/dangermattic/.github/workflows/reusable-retry-buildkite-step-on-events.yml@v1.1.0 + uses: Automattic/dangermattic/.github/workflows/reusable-retry-buildkite-step-on-events.yml@v1.1.2 with: org-slug: "automattic" pipeline-slug: "wordpress-android" diff --git a/.github/workflows/validate-issues.yml b/.github/workflows/validate-issues.yml index c9899d62603d..01b05ac2ffe2 100644 --- a/.github/workflows/validate-issues.yml +++ b/.github/workflows/validate-issues.yml @@ -6,7 +6,7 @@ on: jobs: check-labels-on-issues: - uses: Automattic/dangermattic/.github/workflows/reusable-check-labels-on-issues.yml@v1.0.0 + uses: Automattic/dangermattic/.github/workflows/reusable-check-labels-on-issues.yml@v1.1.2 with: label-format-list: '[ "^\[.+\]", diff --git a/README.md b/README.md index 6de18b6906d5..abdabaee6db2 100644 --- a/README.md +++ b/README.md @@ -16,41 +16,8 @@ If you're a developer wanting to contribute, read on. Notes: -* To use WordPress.com features (login to WordPress.com, access Reader and Stats, etc) you need a WordPress.com OAuth2 ID and secret. Please read the [OAuth2 Authentication](#oauth2-authentication) section. * While loading/building the app in Android Studio ignore the prompt to update the gradle plugin version as that will probably introduce build errors. On the other hand, feel free to update if you are planning to work on ensuring the compatibility of the newer version. - -## OAuth2 Authentication ## - -In order to use WordPress.com functions you will need a client ID and -a client secret key. These details will be used to authenticate your -application and verify that the API calls being made are valid. You can -create an application or view details for your existing applications with -our [WordPress.com applications manager][5]. - -When creating your application, you should select "Native client" for the application type. -The "**Website URL**", "**Redirect URLs**", and "**Javascript Origins**" fields are required but not used for -the mobile apps. Just use "**[https://localhost](https://localhost)**". - -Once you've created your application in the [applications manager][5], you'll -need to edit the `./gradle.properties` file and change the -`wp.oauth.app_id` and `wp.oauth.app_secret` fields. Then you can compile and -run the app on a device or an emulator and try to login with a WordPress.com -account. Note that authenticating to WordPress.com via Google is not supported -in development builds of the app, only in the official release. - -Note that credentials created with our [WordPress.com applications manager][5] -allow login only and not signup. New accounts must be created using the [official app][1] -or [on the web](https://wordpress.com/start). Login is restricted to the WordPress.com -account with which the credentials were created. In other words, if the credentials -were created with foo@email.com, you will only be able to login with foo@email.com. -Using another account like bar@email.com will cause the `Client cannot use "password" grant_type` error. - -For security reasons, some account-related actions aren't supported for development -builds when using a WordPress.com account with 2-factor authentication enabled. - -Read more about [OAuth2][6] and the [WordPress.com REST endpoint][7]. - ## Build and Test ## To build, install, and test the project from the command line: @@ -61,6 +28,13 @@ To build, install, and test the project from the command line: $ ./gradlew :WordPress:testWordPressVanillaDebugUnitTest # assemble, install and run unit tests $ ./gradlew :WordPress:connectedWordPressVanillaDebugAndroidTest # assemble, install and run Android tests +## Running the app ## + +You can use your own WordPress site for developing and testing the app. If you don't have one, you can create a temporary test site for free at https://jurassic.ninja/. +On the app start up screen, choose "Enter your existing site address" and enter the URL of your site and your credentials. + +Note: Access to WordPress.com features is temporarily disabled in the development environment. + ## Directory structure ## . ├── libs # dependencies used to build debug variants diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 9f0ec9aa0d54..8d292613c065 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,7 +2,7 @@ 25.1 ----- - +* [*] [internal] Block editor: Add onContentUpdate bridge functionality [https://github.com/wordpress-mobile/gutenberg-mobile/pull/20852] 25.0 ----- diff --git a/WordPress/build.gradle b/WordPress/build.gradle index ffb8fa889646..0ad75722b44b 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -364,7 +364,7 @@ dependencies { implementation (project(path:':libs:editor')) { exclude group: 'org.wordpress', module: 'utils' } - implementation("$gradle.ext.fluxCBinaryPath") { + implementation("$gradle.ext.fluxCBinaryPath:$wordPressFluxCVersion") { version { strictly wordPressFluxCVersion } @@ -372,7 +372,7 @@ dependencies { exclude group: 'org.wordpress', module: 'utils' exclude group: 'com.android.support', module: 'support-annotations' } - implementation ("$gradle.ext.wputilsBinaryPath") { + implementation ("$gradle.ext.wputilsBinaryPath:$wordPressUtilsVersion") { version { strictly wordPressUtilsVersion } @@ -383,7 +383,7 @@ dependencies { } implementation "$gradle.ext.aboutAutomatticBinaryPath:$automatticAboutVersion" - implementation("$gradle.ext.tracksBinaryPath") { + implementation("$gradle.ext.tracksBinaryPath:$automatticTracksVersion") { version { strictly automatticTracksVersion } diff --git a/WordPress/src/debug/AndroidManifest.xml b/WordPress/src/debug/AndroidManifest.xml index f8c9e84b0392..08c726b25fcd 100644 --- a/WordPress/src/debug/AndroidManifest.xml +++ b/WordPress/src/debug/AndroidManifest.xml @@ -26,6 +26,9 @@ android:name="android.permission.DUMP" tools:ignore="ProtectedPermissions" /> + + + + { diff --git a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt index 11ac7fc132e1..23809e8796ef 100644 --- a/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/inappupdate/InAppUpdateManagerImpl.kt @@ -22,6 +22,10 @@ import com.google.android.play.core.install.model.UpdateAvailability.DEVELOPER_T import com.google.android.play.core.install.model.UpdateAvailability.UPDATE_AVAILABLE import com.google.android.play.core.install.model.UpdateAvailability.UPDATE_NOT_AVAILABLE import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.wordpress.android.inappupdate.IInAppUpdateManager.Companion.APP_UPDATE_FLEXIBLE_REQUEST_CODE import org.wordpress.android.inappupdate.IInAppUpdateManager.Companion.APP_UPDATE_IMMEDIATE_REQUEST_CODE @@ -33,6 +37,7 @@ import javax.inject.Singleton @Suppress("TooManyFunctions") class InAppUpdateManagerImpl( @ApplicationContext private val applicationContext: Context, + private val coroutineScope: CoroutineScope, private val appUpdateManager: AppUpdateManager, private val remoteConfigWrapper: RemoteConfigWrapper, private val buildConfigWrapper: BuildConfigWrapper, @@ -51,8 +56,16 @@ class InAppUpdateManagerImpl( } override fun completeAppUpdate() { - inAppUpdateAnalyticsTracker.trackAppRestartToCompleteUpdate() - appUpdateManager.completeUpdate() + coroutineScope.launch(Dispatchers.Main) { + // Track the app restart to complete update + inAppUpdateAnalyticsTracker.trackAppRestartToCompleteUpdate() + + // Delay so the event above can be logged + delay(RESTART_DELAY_IN_MILLIS) + + // Complete the update + appUpdateManager.completeUpdate() + } } override fun cancelAppUpdate(updateType: Int) { @@ -226,5 +239,6 @@ class InAppUpdateManagerImpl( private const val TAG = "AppUpdateChecker" private const val PREF_NAME = "in_app_update_prefs" private const val KEY_LAST_APP_UPDATE_CHECK_VERSION = "last_app_update_check_version" + private const val RESTART_DELAY_IN_MILLIS = 500L } } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java index ed712b9d32e3..a2d0f248d15b 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java @@ -28,12 +28,19 @@ import org.wordpress.android.ui.sitecreation.SiteCreationStep; import org.wordpress.android.ui.sitecreation.SiteCreationStepsProvider; import org.wordpress.android.util.BuildConfigWrapper; +import org.wordpress.android.util.audio.AudioRecorder; +import org.wordpress.android.util.audio.IAudioRecorder; +import org.wordpress.android.util.audio.RecordingStrategy; +import org.wordpress.android.util.audio.RecordingStrategy.VoiceToContentRecordingStrategy; +import org.wordpress.android.util.audio.VoiceToContentStrategy; import org.wordpress.android.util.config.InAppUpdatesFeatureConfig; import org.wordpress.android.util.config.RemoteConfigWrapper; import org.wordpress.android.util.wizard.WizardManager; import org.wordpress.android.viewmodel.helpers.ConnectionStatus; import org.wordpress.android.viewmodel.helpers.ConnectionStatusLiveData; +import javax.inject.Named; + import dagger.Binds; import dagger.Module; import dagger.Provides; @@ -41,6 +48,8 @@ import dagger.hilt.InstallIn; import dagger.hilt.android.qualifiers.ApplicationContext; import dagger.hilt.components.SingletonComponent; +import kotlinx.coroutines.CoroutineScope; +import static org.wordpress.android.modules.ThreadModuleKt.APPLICATION_SCOPE; @InstallIn(SingletonComponent.class) @Module(includes = AndroidInjectionModule.class) @@ -93,6 +102,7 @@ public static AppUpdateManager provideAppUpdateManager(@ApplicationContext Conte @Provides public static IInAppUpdateManager provideInAppUpdateManager( @ApplicationContext Context context, + @Named(APPLICATION_SCOPE) CoroutineScope appScope, AppUpdateManager appUpdateManager, RemoteConfigWrapper remoteConfigWrapper, BuildConfigWrapper buildConfigWrapper, @@ -103,6 +113,7 @@ public static IInAppUpdateManager provideInAppUpdateManager( return inAppUpdatesFeatureConfig.isEnabled() ? new InAppUpdateManagerImpl( context, + appScope, appUpdateManager, remoteConfigWrapper, buildConfigWrapper, @@ -121,4 +132,19 @@ public static ActivityNavigator provideActivityNavigator(@ApplicationContext Con public static SensorManager provideSensorManager(@ApplicationContext Context context) { return (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); } + + @VoiceToContentStrategy + @Provides + public static IAudioRecorder provideAudioRecorder( + @ApplicationContext Context context, + @VoiceToContentStrategy RecordingStrategy recordingStrategy + ) { + return new AudioRecorder(context, recordingStrategy); + } + + @VoiceToContentStrategy + @Provides + public static RecordingStrategy provideVoiceToContentRecordingStrategy() { + return new VoiceToContentRecordingStrategy(); + } } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java index 634547141d8a..9989e37ba302 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java @@ -54,7 +54,6 @@ import org.wordpress.android.ui.reader.viewmodels.ReaderPostListViewModel; import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel; import org.wordpress.android.ui.reader.viewmodels.SubfilterPageViewModel; -import org.wordpress.android.ui.review.ReviewViewModel; import org.wordpress.android.ui.stats.refresh.lists.DaysListViewModel; import org.wordpress.android.ui.stats.refresh.lists.InsightsDetailListViewModel; import org.wordpress.android.ui.stats.refresh.lists.InsightsListViewModel; @@ -461,11 +460,6 @@ abstract class ViewModelModule { @ViewModelKey(UnifiedCommentListViewModel.class) abstract ViewModel unifiedCommentListViewModel(UnifiedCommentListViewModel viewModel); - @Binds - @IntoMap - @ViewModelKey(ReviewViewModel.class) - abstract ViewModel reviewViewModel(ReviewViewModel viewModel); - @Binds @IntoMap @ViewModelKey(BloggingRemindersViewModel.class) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt index e5df791ee239..53eaa1b35d01 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt @@ -18,21 +18,20 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import org.wordpress.android.ui.compose.theme.AppTheme import androidx.camera.core.Preview as CameraPreview @Composable fun BarcodeScanner( codeScanner: CodeScanner, - onScannedResult: (Flow) -> Unit + onScannedResult: CodeScannerCallback ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + Column( modifier = Modifier.fillMaxSize() ) { @@ -51,30 +50,27 @@ fun BarcodeScanner( .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) .build() imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> - onScannedResult(codeScanner.startScan(imageProxy)) + val callback = object : CodeScannerCallback { + override fun run(status: CodeScannerStatus?) { + status?.let { onScannedResult.run(it) } + } + } + codeScanner.startScan(imageProxy, callback) } try { cameraProviderFuture.get().bindToLifecycle(lifecycleOwner, selector, preview, imageAnalysis) } catch (e: IllegalStateException) { - onScannedResult( - flowOf( - CodeScannerStatus.Failure( - e.message - ?: "Illegal state exception while binding camera provider to lifecycle", - CodeScanningErrorType.Other(e) - ) - ) - ) + onScannedResult.run(CodeScannerStatus.Failure( + e.message + ?: "Illegal state exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + )) } catch (e: IllegalArgumentException) { - onScannedResult( - flowOf( - CodeScannerStatus.Failure( - e.message - ?: "Illegal argument exception while binding camera provider to lifecycle", - CodeScanningErrorType.Other(e) - ) - ) - ) + onScannedResult.run(CodeScannerStatus.Failure( + e.message + ?: "Illegal argument exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + )) } previewView }, @@ -84,8 +80,8 @@ fun BarcodeScanner( } class DummyCodeScanner : CodeScanner { - override fun startScan(imageProxy: ImageProxy): Flow { - return flowOf(CodeScannerStatus.Success("", GoogleBarcodeFormatMapper.BarcodeFormat.FormatUPCA)) + override fun startScan(imageProxy: ImageProxy, callback: CodeScannerCallback) { + callback.run(CodeScannerStatus.Success("", GoogleBarcodeFormatMapper.BarcodeFormat.FormatUPCA)) } } @@ -94,6 +90,10 @@ class DummyCodeScanner : CodeScanner { @Composable private fun BarcodeScannerScreenPreview() { AppTheme { - BarcodeScanner(codeScanner = DummyCodeScanner(), onScannedResult = {}) + BarcodeScanner(codeScanner = DummyCodeScanner(), onScannedResult = object : CodeScannerCallback { + override fun run(status: CodeScannerStatus?) { + // no-ops + } + }) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt index 01a270db9ece..9960cd675da0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R -import kotlinx.coroutines.flow.Flow import org.wordpress.android.ui.compose.theme.AppTheme @Composable @@ -23,7 +22,7 @@ fun BarcodeScannerScreen( codeScanner: CodeScanner, permissionState: BarcodeScanningViewModel.PermissionState, onResult: (Boolean) -> Unit, - onScannedResult: (Flow) -> Unit, + onScannedResult: CodeScannerCallback, ) { val cameraPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt index 73b5f70c0068..434a5a4d8286 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt @@ -12,11 +12,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.util.WPPermissionUtils import javax.inject.Inject @@ -52,12 +48,10 @@ class BarcodeScanningFragment : Fragment() { shouldShowRequestPermissionRationale(KEY_CAMERA_PERMISSION) ) }, - onScannedResult = { codeScannerStatus -> - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - codeScannerStatus.collect { status -> - setResultAndPopStack(status) - } + onScannedResult = object : CodeScannerCallback { + override fun run(status: CodeScannerStatus?) { + if (status != null) { + setResultAndPopStack(status) } } }, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt index 6dde760e0bf0..bcecd01735e4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt @@ -2,11 +2,14 @@ package org.wordpress.android.ui.barcodescanner import android.os.Parcelable import androidx.camera.core.ImageProxy -import kotlinx.coroutines.flow.Flow import kotlinx.parcelize.Parcelize interface CodeScanner { - fun startScan(imageProxy: ImageProxy): Flow + fun startScan(imageProxy: ImageProxy, callback: CodeScannerCallback) +} + +interface CodeScannerCallback { + fun run(status: CodeScannerStatus?) } sealed class CodeScannerStatus : Parcelable { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt index 2e0d339c473e..428bbc5abef0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt @@ -3,10 +3,6 @@ package org.wordpress.android.ui.barcodescanner import androidx.camera.core.ImageProxy import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.common.Barcode -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow import javax.inject.Inject class GoogleMLKitCodeScanner @Inject constructor( @@ -17,53 +13,41 @@ class GoogleMLKitCodeScanner @Inject constructor( ) : CodeScanner { private var barcodeFound = false @androidx.camera.core.ExperimentalGetImage - override fun startScan(imageProxy: ImageProxy): Flow { - return callbackFlow { - val barcodeTask = barcodeScanner.process(inputImageProvider.provideImage(imageProxy)) - barcodeTask.addOnCompleteListener { - // We must call image.close() on received images when finished using them. - // Otherwise, new images may not be received or the camera may stall. - imageProxy.close() - } - barcodeTask.addOnSuccessListener { barcodeList -> - // The check for barcodeFound is done because the startScan method will be called - // continuously by the library as long as we are in the scanning screen. - // There will be a good chance that the same barcode gets identified multiple times and as a result - // success callback will be called multiple times. - if (!barcodeList.isNullOrEmpty() && !barcodeFound) { - barcodeFound = true - handleScanSuccess(barcodeList.firstOrNull()) - this@callbackFlow.close() - } - } - barcodeTask.addOnFailureListener { exception -> - this@callbackFlow.trySend( - CodeScannerStatus.Failure( - error = exception.message, - type = errorMapper.mapGoogleMLKitScanningErrors(exception) - ) - ) - this@callbackFlow.close() + override fun startScan(imageProxy: ImageProxy, callback: CodeScannerCallback) { + val barcodeTask = barcodeScanner.process(inputImageProvider.provideImage(imageProxy)) + barcodeTask.addOnCompleteListener { + // We must call image.close() on received images when finished using them. + // Otherwise, new images may not be received or the camera may stall. + imageProxy.close() + } + barcodeTask.addOnSuccessListener { barcodeList -> + // The check for barcodeFound is done because the startScan method will be called + // continuously by the library as long as we are in the scanning screen. + // There will be a good chance that the same barcode gets identified multiple times and as a result + // success callback will be called multiple times. + if (!barcodeList.isNullOrEmpty() && !barcodeFound) { + barcodeFound = true + callback.run(handleScanSuccess(barcodeList.firstOrNull())) } - - awaitClose() + } + barcodeTask.addOnFailureListener { exception -> + callback.run(CodeScannerStatus.Failure( + error = exception.message, + type = errorMapper.mapGoogleMLKitScanningErrors(exception) + )) } } - private fun ProducerScope.handleScanSuccess(code: Barcode?) { - code?.rawValue?.let { - trySend( - CodeScannerStatus.Success( - it, - barcodeFormatMapper.mapBarcodeFormat(code.format) - ) + private fun handleScanSuccess(code: Barcode?): CodeScannerStatus { + return code?.rawValue?.let { + CodeScannerStatus.Success( + it, + barcodeFormatMapper.mapBarcodeFormat(code.format) ) } ?: run { - trySend( - CodeScannerStatus.Failure( - error = "Failed to find a valid raw value!", - type = CodeScanningErrorType.Other(Throwable("Empty raw value")) - ) + CodeScannerStatus.Failure( + error = "Failed to find a valid raw value!", + type = CodeScanningErrorType.Other(Throwable("Empty raw value")) ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/MainActionListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/MainActionListItem.kt index c64a2df2e86b..755e674dfc0c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/MainActionListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/MainActionListItem.kt @@ -14,7 +14,7 @@ sealed class MainActionListItem { CREATE_NEW_PAGE_FROM_PAGES_CARD, CREATE_NEW_POST, ANSWER_BLOGGING_PROMPT, - CREATE_NEW_POST_FROM_AUDIO_AI + CREATE_NEW_POST_FROM_AUDIO } data class CreateAction( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 8b4281d9cb94..89bffc50e51b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -28,20 +28,14 @@ import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; -import com.google.android.gms.tasks.Task; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; import com.google.android.play.core.install.model.AppUpdateType; -import com.google.android.play.core.review.ReviewInfo; -import com.google.android.play.core.review.ReviewManager; -import com.google.android.play.core.review.ReviewManagerFactory; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.wordpress.android.BuildConfig; -import org.wordpress.android.inappupdate.InAppUpdateListener; -import org.wordpress.android.inappupdate.IInAppUpdateManager; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.analytics.AnalyticsTracker; @@ -71,6 +65,8 @@ import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged; import org.wordpress.android.fluxc.store.SiteStore.OnSiteEditorsChanged; import org.wordpress.android.fluxc.store.SiteStore.OnSiteRemoved; +import org.wordpress.android.inappupdate.IInAppUpdateManager; +import org.wordpress.android.inappupdate.InAppUpdateListener; import org.wordpress.android.login.LoginAnalyticsListener; import org.wordpress.android.networking.ConnectionChangeReceiver; import org.wordpress.android.push.GCMMessageHandler; @@ -139,7 +135,6 @@ import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask; import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter; import org.wordpress.android.ui.reader.tracker.ReaderTracker; -import org.wordpress.android.ui.review.ReviewViewModel; import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource; import org.wordpress.android.ui.stats.StatsTimeframe; import org.wordpress.android.ui.stats.refresh.utils.StatsLaunchedFrom; @@ -178,7 +173,7 @@ import org.wordpress.android.viewmodel.main.WPMainActivityViewModel.FocusPointInfo; import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel; import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel.CreatePageDashboardSource; -import org.wordpress.android.widgets.AppRatingDialog; +import org.wordpress.android.widgets.AppReviewManager; import org.wordpress.android.widgets.WPSnackbar; import org.wordpress.android.workers.notification.createsite.CreateSiteNotificationScheduler; import org.wordpress.android.workers.weeklyroundup.WeeklyRoundupScheduler; @@ -196,7 +191,6 @@ import static org.wordpress.android.login.LoginAnalyticsListener.CreatedAccountSource.EMAIL; import static org.wordpress.android.push.NotificationsProcessingService.ARG_NOTIFICATION_TYPE; import static org.wordpress.android.ui.JetpackConnectionSource.NOTIFICATIONS; -import static org.wordpress.android.util.extensions.InAppReviewExtensionsKt.logException; import dagger.hilt.android.AndroidEntryPoint; import kotlin.Unit; @@ -261,7 +255,6 @@ public class WPMainActivity extends LocaleAwareActivity implements private WPMainActivityViewModel mViewModel; private ModalLayoutPickerViewModel mMLPViewModel; - @NonNull private ReviewViewModel mReviewViewModel; private BloggingRemindersViewModel mBloggingRemindersViewModel; private NotificationsListViewModel mNotificationsViewModel; private FloatingActionButton mFloatingActionButton; @@ -509,7 +502,7 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) { } if (canShowAppRatingPrompt) { - AppRatingDialog.INSTANCE.showRateDialogIfNeeded(getSupportFragmentManager()); + AppReviewManager.INSTANCE.showRateDialogIfNeeded(getSupportFragmentManager()); } scheduleLocalNotifications(); @@ -691,7 +684,6 @@ private void initViewModel() { mViewModel = new ViewModelProvider(this, mViewModelFactory).get(WPMainActivityViewModel.class); mMLPViewModel = new ViewModelProvider(this, mViewModelFactory).get(ModalLayoutPickerViewModel.class); - mReviewViewModel = new ViewModelProvider(this, mViewModelFactory).get(ReviewViewModel.class); mBloggingRemindersViewModel = new ViewModelProvider(this, mViewModelFactory).get(BloggingRemindersViewModel.class); @@ -720,7 +712,7 @@ private void initViewModel() { mViewModel.getCreateAction().observe(this, createAction -> { switch (createAction) { - case CREATE_NEW_POST_FROM_AUDIO_AI: + case CREATE_NEW_POST_FROM_AUDIO: launchVoiceToContent(); break; case CREATE_NEW_POST: @@ -790,11 +782,6 @@ private void initViewModel() { }); }); - mReviewViewModel.getLaunchReview().observe(this, event -> event.applyIfNotHandled(unit -> { - launchInAppReviews(); - return null; - })); - BloggingReminderUtils.observeBottomSheet( mBloggingRemindersViewModel.isBottomSheetShowing(), this, @@ -862,20 +849,6 @@ private void triggerCreatePageFlow(ActionType actionType) { } } - private void launchInAppReviews() { - ReviewManager manager = ReviewManagerFactory.create(this); - Task request = manager.requestReviewFlow(); - request.addOnCompleteListener(task -> { - if (task.isSuccessful()) { - ReviewInfo reviewInfo = task.getResult(); - Task flow = manager.launchReviewFlow(this, reviewInfo); - flow.addOnFailureListener(e -> AppLog.e(T.MAIN, "Error launching google review API flow.", e)); - } else { - logException(task); - } - }); - } - private CreatePageDashboardSource getCreatePageDashboardSourceFromActionType(ActionType actionType) { if (actionType == ActionType.CREATE_NEW_PAGE_FROM_PAGES_CARD) { return CreatePageDashboardSource.PAGES_CARD; @@ -1208,6 +1181,9 @@ protected void onResume() { && mBottomNav.getCurrentSelectedPage() == PageType.MY_SITE ); + if (AppReviewManager.INSTANCE.shouldShowInAppReviewsPrompt()) { + AppReviewManager.INSTANCE.launchInAppReviews(this); + } checkForInAppUpdate(); mIsChangingConfiguration = false; @@ -1425,7 +1401,9 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { v -> UploadUtils.publishPost(WPMainActivity.this, post, site, mDispatcher), isFirstTimePublishing -> { mBloggingRemindersViewModel.onPublishingPost(site.getId(), isFirstTimePublishing); - mReviewViewModel.onPublishingPost(isFirstTimePublishing); + if (isFirstTimePublishing) { + AppReviewManager.INSTANCE.onPostPublished(); + } } ); } @@ -1833,7 +1811,9 @@ public void onPostUploaded(OnPostUploaded event) { targetSite, isFirstTimePublishing -> { mBloggingRemindersViewModel.onPublishingPost(targetSite.getId(), isFirstTimePublishing); - mReviewViewModel.onPublishingPost(isFirstTimePublishing); + if (isFirstTimePublishing) { + AppReviewManager.INSTANCE.onPostPublished(); + } } ); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java index 2daf9e68f650..368c18fbe8a2 100755 --- a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java @@ -91,7 +91,7 @@ import org.wordpress.android.util.WPMediaUtils; import org.wordpress.android.util.WPPermissionUtils; import org.wordpress.android.util.analytics.AnalyticsUtils; -import org.wordpress.android.widgets.AppRatingDialog; +import org.wordpress.android.widgets.AppReviewManager; import org.wordpress.android.widgets.QuickStartFocusPoint; import java.util.ArrayList; @@ -1097,7 +1097,7 @@ private void addMediaToUploadService(@NonNull ArrayList mediaModels) } UploadService.uploadMedia(this, mediaModels, "MediaBrowserActivity#addMediaToUploadService"); - AppRatingDialog.INSTANCE.incrementInteractions(APP_REVIEWS_EVENT_INCREMENTED_BY_UPLOADING_MEDIA); + AppReviewManager.INSTANCE.incrementInteractions(APP_REVIEWS_EVENT_INCREMENTED_BY_UPLOADING_MEDIA); } private void queueFileForUpload(Uri uri, String mimeType) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt index 101f2be19bfb..b96defd836b5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt @@ -255,11 +255,13 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { } requireNotNull(notification).let { note -> - ReaderActivityLauncher.showReaderComments( - activity, note.siteId.toLong(), note.postId.toLong(), - note.commentId, - COMMENT_NOTIFICATION.sourceDescription - ) + context?.let { nonNullContext -> + ReaderActivityLauncher.showReaderComments( + nonNullContext, note.siteId.toLong(), note.postId.toLong(), + note.commentId, + COMMENT_NOTIFICATION.sourceDescription + ) + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt index b237f0669151..bbefe73e593a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt @@ -72,7 +72,7 @@ import org.wordpress.android.util.NetworkUtils import org.wordpress.android.util.WPSwipeToRefreshHelper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import org.wordpress.android.util.helpers.SwipeToRefreshHelper -import org.wordpress.android.widgets.AppRatingDialog.incrementInteractions +import org.wordpress.android.widgets.AppReviewManager.incrementInteractions import javax.inject.Inject @AndroidEntryPoint @@ -225,13 +225,15 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l viewModel.openNote( noteId, { siteId, postId, commentId -> - ReaderActivityLauncher.showReaderComments( - activity, - siteId, - postId, - commentId, - ThreadedCommentsActionSource.COMMENT_NOTIFICATION.sourceDescription - ) + activity?.let { + ReaderActivityLauncher.showReaderComments( + it, + siteId, + postId, + commentId, + ThreadedCommentsActionSource.COMMENT_NOTIFICATION.sourceDescription + ) + } }, { // Open the latest version of this note in case it has changed, which can happen if the note was diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt index cf1a761f4d56..966fdd683df5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt @@ -35,6 +35,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper import org.wordpress.android.util.ToastUtilsWrapper import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ScopedViewModel +import org.wordpress.android.widgets.AppReviewsManagerWrapper import javax.inject.Inject import javax.inject.Named @@ -48,6 +49,7 @@ class NotificationsListViewModel @Inject constructor( private val networkUtilsWrapper: NetworkUtilsWrapper, private val toastUtilsWrapper: ToastUtilsWrapper, private val notificationsUtilsWrapper: NotificationsUtilsWrapper, + private val appReviewsManagerWrapper: AppReviewsManagerWrapper, private val appLogWrapper: AppLogWrapper, private val siteStore: SiteStore, private val commentStore: CommentsStore, @@ -141,6 +143,7 @@ class NotificationsListViewModel @Inject constructor( openDetailView: () -> Unit ) { val note = noteId?.let { notificationsUtilsWrapper.getNoteById(noteId) } + note?.let { appReviewsManagerWrapper.onNotificationReceived(it) } if (note != null && note.isCommentType && !note.canModerate()) { val readerPost = readerPostTableWrapper.getBlogPost(note.siteId.toLong(), note.postId.toLong(), false) if (readerPost != null) { @@ -158,7 +161,8 @@ class NotificationsListViewModel @Inject constructor( appLogWrapper.w(AppLog.T.NOTIFS, "Failed to fetch post for comment: $statusCode") openDetailView() } - }) + } + ) } } else { openDetailView() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt index 6933c9c0bf12..d2659bf9c17f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt @@ -244,7 +244,7 @@ import org.wordpress.android.util.image.ImageType import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.helpers.ToastMessageHolder import org.wordpress.android.viewmodel.storage.StorageUtilsViewModel -import org.wordpress.android.widgets.AppRatingDialog.incrementInteractions +import org.wordpress.android.widgets.AppReviewManager.incrementInteractions import org.wordpress.android.widgets.WPSnackbar.Companion.make import org.wordpress.android.widgets.WPViewPager import org.wordpress.aztec.AztecExceptionHandler diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt index c89268b5853a..bbfe0509bfe6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt @@ -21,7 +21,6 @@ import androidx.appcompat.widget.Toolbar import androidx.lifecycle.ViewModelProvider import androidx.viewpager.widget.ViewPager.OnPageChangeListener import com.google.android.material.snackbar.Snackbar -import com.google.android.play.core.review.ReviewManagerFactory import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.PostListActivityBinding @@ -52,7 +51,6 @@ import org.wordpress.android.ui.posts.prepublishing.PrepublishingBottomSheetFrag import org.wordpress.android.ui.posts.prepublishing.PrepublishingBottomSheetFragment.Companion.newInstance import org.wordpress.android.ui.posts.prepublishing.home.PublishPost import org.wordpress.android.ui.posts.prepublishing.listeners.PrepublishingBottomSheetListener -import org.wordpress.android.ui.review.ReviewViewModel import org.wordpress.android.ui.uploads.UploadActionUseCase import org.wordpress.android.ui.uploads.UploadUtilsWrapper import org.wordpress.android.ui.utils.UiHelpers @@ -61,10 +59,10 @@ import org.wordpress.android.util.SnackbarItem import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.extensions.getSerializableCompat import org.wordpress.android.util.extensions.getSerializableExtraCompat -import org.wordpress.android.util.extensions.logException import org.wordpress.android.util.extensions.redirectContextClickToLongPressListener import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout import org.wordpress.android.viewmodel.observeEvent +import org.wordpress.android.widgets.AppReviewManager import javax.inject.Inject import android.R as AndroidR @@ -122,9 +120,6 @@ class PostsListActivity : LocaleAwareActivity(), @Inject internal lateinit var bloggingRemindersViewModel: BloggingRemindersViewModel - @Inject - internal lateinit var reviewViewModel: ReviewViewModel - @Inject internal lateinit var blazeFeatureUtils: BlazeFeatureUtils @@ -211,7 +206,6 @@ class PostsListActivity : LocaleAwareActivity(), initViewModel(initPreviewState, currentBottomSheetPostId) initSearchFragment() initBloggingReminders() - initInAppReviews() initTabLayout(tabIndex) loadIntentData(intent) } @@ -337,27 +331,6 @@ class PostsListActivity : LocaleAwareActivity(), } } - private fun initInAppReviews() { - reviewViewModel = ViewModelProvider(this@PostsListActivity, viewModelFactory)[ReviewViewModel::class.java] - reviewViewModel.launchReview.observeEvent(this) { launchInAppReviews() } - } - - private fun launchInAppReviews() { - val manager = ReviewManagerFactory.create(this) - val request = manager.requestReviewFlow() - request.addOnCompleteListener { task -> - if (task.isSuccessful) { - val reviewInfo = task.result - val flow = manager.launchReviewFlow(this, reviewInfo) - flow.addOnFailureListener { e -> - AppLog.e(AppLog.T.POSTS, "Error launching google review API flow.", e) - } - } else { - task.logException() - } - } - } - private fun PostListActivityBinding.setupActions() { viewModel.dialogAction.observe(this@PostsListActivity) { it?.show(this@PostsListActivity, supportFragmentManager, uiHelpers) @@ -381,7 +354,9 @@ class PostsListActivity : LocaleAwareActivity(), ) { isFirstTimePublishing -> changeTabsOnPostUpload() bloggingRemindersViewModel.onPublishingPost(site.id, isFirstTimePublishing) - reviewViewModel.onPublishingPost(isFirstTimePublishing) + if (isFirstTimePublishing) { + AppReviewManager.onPostPublished() + } } } } @@ -452,6 +427,9 @@ class PostsListActivity : LocaleAwareActivity(), override fun onResume() { super.onResume() ActivityId.trackLastActivity(ActivityId.POSTS) + if (AppReviewManager.shouldShowInAppReviewsPrompt()) { + AppReviewManager.launchInAppReviews(this) + } } @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index e8f7950d4568..cea12f681a01 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -162,9 +162,10 @@ public enum DeletablePrefKey implements PrefKey { SITE_JETPACK_CAPABILITIES, REMOVED_QUICK_START_CARD_TYPE, PINNED_DYNAMIC_CARD, - // PUBLISHED_POST_COUNT will increase until it reaches ReviewViewModel.TARGET_COUNT_POST_PUBLISHED + // PUBLISHED_POST_COUNT will increase until it reaches AppReviewManager.TARGET_COUNT_POST_PUBLISHED PUBLISHED_POST_COUNT, - IN_APP_REVIEW_SHOWN, + // PUBLISHED_POST_COUNT will increase until it reaches AppReviewManager.TARGET_COUNT_NOTIFICATIONS + IN_APP_REVIEWS_NOTIFICATION_COUNT, BLOGGING_REMINDERS_SHOWN, SHOULD_SCHEDULE_CREATE_SITE_NOTIFICATION, SHOULD_SHOW_WEEKLY_ROUNDUP_NOTIFICATION, @@ -1303,16 +1304,24 @@ public static void incrementPublishedPostCount() { putInt(DeletablePrefKey.PUBLISHED_POST_COUNT, getPublishedPostCount() + 1); } + public static void resetPublishedPostCount() { + remove(DeletablePrefKey.PUBLISHED_POST_COUNT); + } + public static int getPublishedPostCount() { return prefs().getInt(DeletablePrefKey.PUBLISHED_POST_COUNT.name(), 0); } - public static void setInAppReviewsShown() { - putBoolean(DeletablePrefKey.IN_APP_REVIEW_SHOWN, true); + public static void incrementInAppReviewsNotificationCount() { + putInt(DeletablePrefKey.IN_APP_REVIEWS_NOTIFICATION_COUNT, getInAppReviewsNotificationCount() + 1); + } + + public static int getInAppReviewsNotificationCount() { + return prefs().getInt(DeletablePrefKey.IN_APP_REVIEWS_NOTIFICATION_COUNT.name(), 0); } - public static boolean isInAppReviewsShown() { - return prefs().getBoolean(DeletablePrefKey.IN_APP_REVIEW_SHOWN.name(), false); + public static void resetInAppReviewsNotificationCount() { + remove(DeletablePrefKey.IN_APP_REVIEWS_NOTIFICATION_COUNT); } public static void setBloggingRemindersShown(int siteId) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 35748c160639..df7fcf5d0b85 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -196,19 +196,14 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun incrementPublishedPostCount() { AppPrefs.incrementPublishedPostCount() } + fun resetPublishedPostCount() { + AppPrefs.resetPublishedPostCount() + } fun getPublishedPostCount(): Int { return AppPrefs.getPublishedPostCount() } - fun setInAppReviewsShown() { - AppPrefs.setInAppReviewsShown() - } - - fun isInAppReviewsShown(): Boolean { - return AppPrefs.isInAppReviewsShown() - } - fun setBloggingRemindersShown(siteId: Int) { AppPrefs.setBloggingRemindersShown(siteId) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java index d8167a10c791..2712938f4ccd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java @@ -201,7 +201,7 @@ public static Intent createReaderSearchIntent(@NonNull final Context context) { /* * show comments for the passed Ids */ - public static void showReaderComments(Context context, + public static void showReaderComments(@NonNull Context context, long blogId, long postId, String source) { @@ -213,7 +213,7 @@ public static void showReaderComments(Context context, * show specific comment for the passed Ids */ public static void showReaderComments( - Context context, + @NonNull Context context, long blogId, long postId, long commentId, @@ -240,7 +240,7 @@ public static void showReaderComments( * @param commentId specific comment id to perform an action on * @param interceptedUri URI to fall back into (i.e. to be able to open in external browser) */ - public static void showReaderComments(Context context, long blogId, long postId, DirectOperation + public static void showReaderComments(@NonNull Context context, long blogId, long postId, DirectOperation directOperation, long commentId, String interceptedUri, String source) { Intent intent = buildShowReaderCommentsIntent( context, @@ -255,7 +255,7 @@ public static void showReaderComments(Context context, long blogId, long postId, } public static void showReaderCommentsForResult( - Fragment fragment, + @NonNull Fragment fragment, long blogId, long postId, String source @@ -263,8 +263,11 @@ public static void showReaderCommentsForResult( showReaderCommentsForResult(fragment, blogId, postId, null, 0, null, source); } - public static void showReaderCommentsForResult(Fragment fragment, long blogId, long postId, DirectOperation + public static void showReaderCommentsForResult(@NonNull Fragment fragment, long blogId, long postId, DirectOperation directOperation, long commentId, String interceptedUri, String source) { + if (fragment.getContext() == null) { + return; + } Intent intent = buildShowReaderCommentsIntent( fragment.getContext(), blogId, @@ -277,8 +280,8 @@ public static void showReaderCommentsForResult(Fragment fragment, long blogId, l fragment.startActivityForResult(intent, RequestCodes.READER_FOLLOW_CONVERSATION); } - private static Intent buildShowReaderCommentsIntent(Context context, long blogId, long postId, DirectOperation - directOperation, long commentId, String interceptedUri, String source) { + private static Intent buildShowReaderCommentsIntent(@NonNull Context context, long blogId, long postId, + DirectOperation directOperation, long commentId, String interceptedUri, String source) { Intent intent = new Intent( context, ReaderCommentListActivity.class diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt index 6b69d3320af1..12898f1f38de 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt @@ -1581,11 +1581,13 @@ class ReaderPostDetailFragment : ViewPagerFragment(), private fun handleDirectOperation() = when (directOperation) { DirectOperation.COMMENT_JUMP, DirectOperation.COMMENT_REPLY, DirectOperation.COMMENT_LIKE -> { viewModel.post?.let { - ReaderActivityLauncher.showReaderComments( - activity, it.blogId, it.postId, - directOperation, commentId.toLong(), viewModel.interceptedUri, - DIRECT_OPERATION.sourceDescription - ) + context?.let { nonNullContext -> + ReaderActivityLauncher.showReaderComments( + nonNullContext, it.blogId, it.postId, + directOperation, commentId.toLong(), viewModel.interceptedUri, + DIRECT_OPERATION.sourceDescription + ) + } } activity?.finish() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java index 98665420c1e2..d62a859bfdeb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java @@ -140,7 +140,7 @@ import org.wordpress.android.util.config.ReaderImprovementsFeatureConfig; import org.wordpress.android.util.config.SeenUnseenWithCounterFeatureConfig; import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.widgets.AppRatingDialog; +import org.wordpress.android.widgets.AppReviewManager; import org.wordpress.android.widgets.RecyclerItemDecoration; import org.wordpress.android.widgets.WPSnackbar; @@ -2436,7 +2436,7 @@ public void onPostSelected(ReaderPost post) { return; } - AppRatingDialog.INSTANCE.incrementInteractions( + AppReviewManager.INSTANCE.incrementInteractions( AnalyticsTracker.Stat.APP_REVIEWS_EVENT_INCREMENTED_BY_OPENING_READER_POST ); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt index 62a8d22ae378..6bc60f163672 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt @@ -172,12 +172,14 @@ class ReaderDiscoverFragment : ViewPagerFragment(R.layout.reader_discover_fragme is ShowPostDetail -> ReaderActivityLauncher.showReaderPostDetail(context, event.post.blogId, event.post.postId) is SharePost -> ReaderActivityLauncher.sharePost(context, event.post) is OpenPost -> ReaderActivityLauncher.openPost(context, event.post) - is ShowReaderComments -> ReaderActivityLauncher.showReaderComments( - context, - event.blogId, - event.postId, - READER_POST_CARD.sourceDescription - ) + is ShowReaderComments -> context?.let { + ReaderActivityLauncher.showReaderComments( + it, + event.blogId, + event.postId, + READER_POST_CARD.sourceDescription + ) + } is ShowNoSitesToReblog -> ReaderActivityLauncher.showNoSiteToReblog(activity) is ShowSitePickerForResult -> ActivityLauncher.showSitePickerForResult( this@ReaderDiscoverFragment, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt index 213ddc2fd3ac..7b3055f380dc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt @@ -78,7 +78,7 @@ import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ResourceProvider -import org.wordpress.android.widgets.AppRatingDialogWrapper +import org.wordpress.android.widgets.AppReviewsManagerWrapper import javax.inject.Inject import javax.inject.Named @@ -97,7 +97,7 @@ class ReaderPostCardActionsHandler @Inject constructor( private val dispatcher: Dispatcher, private val resourceProvider: ResourceProvider, private val htmlMessageUtils: HtmlMessageUtils, - private val appRatingDialogWrapper: AppRatingDialogWrapper, + private val appReviewsManagerWrapper: AppReviewsManagerWrapper, private val seenStatusToggleUseCase: ReaderSeenStatusToggleUseCase, private val readerBlogTableWrapper: ReaderBlogTableWrapper, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher @@ -207,7 +207,7 @@ class ReaderPostCardActionsHandler @Inject constructor( source: String ) { withContext(bgDispatcher) { - appRatingDialogWrapper.incrementInteractions( + appReviewsManagerWrapper.incrementInteractions( AnalyticsTracker.Stat.APP_REVIEWS_EVENT_INCREMENTED_BY_OPENING_READER_POST ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt deleted file mode 100644 index c54010baac66..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.wordpress.android.ui.review - -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import org.wordpress.android.ui.prefs.AppPrefsWrapper -import org.wordpress.android.viewmodel.Event -import javax.inject.Inject - -class ReviewViewModel @Inject constructor(private val appPrefsWrapper: AppPrefsWrapper) : ViewModel() { - private val _launchReview = MutableLiveData>() - val launchReview = _launchReview as LiveData> - - fun onPublishingPost(isFirstTimePublishing: Boolean) { - if (!appPrefsWrapper.isInAppReviewsShown() && isFirstTimePublishing) { - if (appPrefsWrapper.getPublishedPostCount() < TARGET_COUNT_POST_PUBLISHED) { - appPrefsWrapper.incrementPublishedPostCount() - } - if (appPrefsWrapper.getPublishedPostCount() == TARGET_COUNT_POST_PUBLISHED) { - _launchReview.value = Event(Unit) - appPrefsWrapper.setInAppReviewsShown() - } - } - } - - companion object { - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - const val TARGET_COUNT_POST_PUBLISHED = 2 - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/domains/SiteCreationDomainsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/domains/SiteCreationDomainsViewModel.kt index 2881f623475b..5302e66a4db8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/domains/SiteCreationDomainsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitecreation/domains/SiteCreationDomainsViewModel.kt @@ -122,7 +122,6 @@ class SiteCreationDomainsViewModel @Inject constructor( } else -> { - AppLog.d(AppLog.T.DOMAIN_REGISTRATION, result.products.toString()) products = result.products.orEmpty().associateBy { it.productId } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt new file mode 100644 index 000000000000..3552ea314fe2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/RecordingUseCase.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.voicetocontent + +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.util.audio.IAudioRecorder +import org.wordpress.android.util.audio.RecordingUpdate +import org.wordpress.android.util.audio.VoiceToContentStrategy +import javax.inject.Inject +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult + +class RecordingUseCase @Inject constructor( + @VoiceToContentStrategy private val audioRecorder: IAudioRecorder +) { + fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) { + audioRecorder.startRecording(onRecordingFinished) + } + + @Suppress("ReturnCount") + fun stopRecording() { + audioRecorder.stopRecording() + } + + fun recordingUpdates(): Flow { + return audioRecorder.recordingUpdates() + } +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt index 6e93b50a2eab..25b39aefa519 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentDialogFragment.kt @@ -1,9 +1,14 @@ package org.wordpress.android.ui.voicetocontent +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -27,7 +32,10 @@ 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 androidx.core.content.ContextCompat import org.wordpress.android.R +import org.wordpress.android.util.audio.IAudioRecorder.Companion.REQUIRED_RECORDING_PERMISSIONS +import android.provider.Settings @AndroidEntryPoint class VoiceToContentDialogFragment : BottomSheetDialogFragment() { @@ -38,11 +46,57 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() { ): View = ComposeView(requireContext()).apply { setContent { AppTheme { - VoiceToContentScreen(viewModel) + VoiceToContentScreen( + viewModel = viewModel, + onRequestPermission = { requestAllPermissionsForRecording() }, + hasPermission = { hasAllPermissionsForRecording() } + ) + } + } + } + + private val requestMultiplePermissionsLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val areAllPermissionsGranted = permissions.entries.all { it.value } + if (areAllPermissionsGranted) { + viewModel.startRecording() + } else { + // Check if any permissions were denied permanently + if (permissions.entries.any { !it.value }) { + showPermissionDeniedDialog() } } } + private fun hasAllPermissionsForRecording(): Boolean { + return REQUIRED_RECORDING_PERMISSIONS.all { + ContextCompat.checkSelfPermission( + requireContext(), + it + ) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestAllPermissionsForRecording() { + requestMultiplePermissionsLauncher.launch(REQUIRED_RECORDING_PERMISSIONS) + } + + private fun showPermissionDeniedDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.voice_to_content_permissions_required_title) + .setMessage(R.string.voice_to_content_permissions_required_msg) + .setPositiveButton("Settings") { _, _ -> + // Open the app's settings + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", requireContext().packageName, null) + } + startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + } + companion object { const val TAG = "voice_to_content_fragment_tag" @@ -52,8 +106,13 @@ class VoiceToContentDialogFragment : BottomSheetDialogFragment() { } @Composable -fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) { +fun VoiceToContentScreen( + viewModel: VoiceToContentViewModel, + onRequestPermission: () -> Unit, + hasPermission: () -> Boolean +) { val result by viewModel.uiState.observeAsState() + val assistantFeature by viewModel.aiAssistantFeatureState.observeAsState() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -69,6 +128,11 @@ fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) { Text(text = result?.content!!, fontSize = 20.sp, fontWeight = FontWeight.Bold) } + assistantFeature != null -> { + Text(text = "Assistant Feature Returned Successfully", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(16.dp)) + } + else -> { Text(text = "Ready to fake record - tap microphone", fontSize = 20.sp, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(16.dp)) @@ -77,7 +141,24 @@ fun VoiceToContentScreen(viewModel: VoiceToContentViewModel) { contentDescription = "Microphone", modifier = Modifier .size(64.dp) - .clickable { viewModel.execute() } + .clickable { + if (hasPermission()) { + viewModel.startRecording() + } else { + onRequestPermission() + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + Icon( + painterResource(id = com.google.android.exoplayer2.ui.R.drawable.exo_icon_stop), + contentDescription = "Stop", + modifier = Modifier + .size(64.dp) + .clickable { + viewModel.stopRecording() + } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtils.kt index 9c2e1b428682..4455ff7752c0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtils.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.voicetocontent +import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.config.VoiceToContentFeatureConfig import javax.inject.Inject @@ -8,5 +9,30 @@ class VoiceToContentFeatureUtils @Inject constructor( private val buildConfigWrapper: BuildConfigWrapper, private val voiceToContentFeatureConfig: VoiceToContentFeatureConfig ) { - fun isVoiceToContentEnabled() = buildConfigWrapper.isJetpackApp && voiceToContentFeatureConfig.isEnabled() + // todo: remove buildConfigWrapper.isDebug() when Voice to content is ready for release + fun isVoiceToContentEnabled() = buildConfigWrapper.isJetpackApp + && voiceToContentFeatureConfig.isEnabled() + && buildConfigWrapper.isDebug() + + fun isEligibleForVoiceToContent(jetpackFeatureAIAssistantFeature: JetpackAIAssistantFeature) = + !jetpackFeatureAIAssistantFeature.siteRequireUpgrade + + fun getRequestLimit(jetpackFeatureAIAssistantFeature: JetpackAIAssistantFeature): Int { + return with(jetpackFeatureAIAssistantFeature) { + val calculatedLimit = if (currentTier?.slug == JETPACK_AI_FREE) { + maxOf(0, requestsLimit - requestsCount) + } else if (currentTier?.value == 1) { + Int.MAX_VALUE + } else { + val requestsLimit = currentTier?.limit ?: requestsLimit + val requestsCount = usagePeriod?.requestsCount ?: requestsCount + maxOf(0, requestsLimit - requestsCount) + } + calculatedLimit + } + } + + companion object { + private const val JETPACK_AI_FREE = "jetpack_ai_free" + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUseCase.kt index 4603a6d88a78..88edbf2e5f60 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentUseCase.kt @@ -1,79 +1,78 @@ package org.wordpress.android.ui.voicetocontent -import android.content.Context +import android.util.Log 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.network.rest.wpcom.jetpackai.JetpackAIQueryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionResponse 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 + private val jetpackAIStore: JetpackAIStore ) { companion object { const val FEATURE = "voice_to_content" - private const val KILO_BYTE = 1024 + const val ROLE = "jetpack-ai" + const val TYPE = "voice-to-content-simple-draft" + const val JETPACK_AI_ERROR = "__JETPACK_AI_ERROR__" } suspend fun execute( siteModel: SiteModel, + file: File ): VoiceToContentResult = withContext(Dispatchers.IO) { - val file = getAudioFile() ?: return@withContext VoiceToContentResult(isError = true) - val response = jetpackAIStore.fetchJetpackAITranscription( + val transcriptionResponse = jetpackAIStore.fetchJetpackAITranscription( siteModel, FEATURE, file ) - when(response) { - is JetpackAITranscriptionRestClient.JetpackAITranscriptionResponse.Success -> { - return@withContext VoiceToContentResult(content = response.model) + val transcribedText: String? = when(transcriptionResponse) { + is JetpackAITranscriptionResponse.Success -> { + transcriptionResponse.model } - is JetpackAITranscriptionRestClient.JetpackAITranscriptionResponse.Error -> { - return@withContext VoiceToContentResult(isError = true) + is JetpackAITranscriptionResponse.Error -> { + val message = "${transcriptionResponse.type} ${transcriptionResponse.message}" + Log.i( + javaClass.simpleName, + "Error transcribing audio file: $message" + ) + null } } - } - - // 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 - } - } + transcribedText?.let { + val response = jetpackAIStore.fetchJetpackAIQuery( + site = siteModel, + feature = FEATURE, + role = ROLE, + message = it, + stream = false, + type = TYPE + ) - // 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 - } + when(response) { + is JetpackAIQueryResponse.Success -> { + val finalContent: String = response.choices[0].message.content + // __JETPACK_AI_ERROR__ is a special marker we ask GPT to add to the request when it can’t + // understand the request for any reason, so maybe something confused GPT on some requests. + if (finalContent == JETPACK_AI_ERROR) { + return@withContext VoiceToContentResult(isError = true) + } else { + return@withContext VoiceToContentResult(content = response.choices[0].message.content) + } + } - 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() + is JetpackAIQueryResponse.Error -> { + return@withContext VoiceToContentResult(isError = true) + } + } + } ?:return@withContext VoiceToContentResult(isError = true) } - inputStream.close() - } } // todo: build out the result object diff --git a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt index e889027ef9cb..845d7e533f98 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModel.kt @@ -1,40 +1,120 @@ package org.wordpress.android.ui.voicetocontent +import android.util.Log 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.Dispatchers import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIAssistantFeatureResponse +import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ScopedViewModel +import java.io.File import javax.inject.Inject import javax.inject.Named +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error @HiltViewModel class VoiceToContentViewModel @Inject constructor( @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, private val voiceToContentFeatureUtils: VoiceToContentFeatureUtils, private val voiceToContentUseCase: VoiceToContentUseCase, - private val selectedSiteRepository: SelectedSiteRepository + private val selectedSiteRepository: SelectedSiteRepository, + private val jetpackAIStore: JetpackAIStore, + private val recordingUseCase: RecordingUseCase ) : ScopedViewModel(mainDispatcher) { private val _uiState = MutableLiveData() val uiState = _uiState as LiveData + private val _aiAssistantFeatureState = MutableLiveData() + val aiAssistantFeatureState = _aiAssistantFeatureState as LiveData + private fun isVoiceToContentEnabled() = voiceToContentFeatureUtils.isVoiceToContentEnabled() - fun execute() { + init { + observeRecordingUpdates() + } + + private fun observeRecordingUpdates() { + viewModelScope.launch { + recordingUseCase.recordingUpdates().collect { update -> + if (update.fileSizeLimitExceeded) { + stopRecording() + } else { + // todo: Handle other updates if needed when UI is ready, e.g., elapsed time and file size + Log.d("AudioRecorder", "Recording update: $update") + } + } + } + } + + fun startRecording() { + recordingUseCase.startRecording { audioRecorderResult -> + when (audioRecorderResult) { + is Success -> { + val file = getRecordingFile(audioRecorderResult.recordingPath) + file?.let { + executeVoiceToContent(it) + } ?: run { + _uiState.postValue(VoiceToContentResult(isError = true)) + } + } + is Error -> { + _uiState.postValue(VoiceToContentResult(isError = true)) + } + } + } + } + + @Suppress("ReturnCount") + private fun getRecordingFile(recordingPath: String): File? { + if (recordingPath.isEmpty()) return null + val recordingFile = File(recordingPath) + // Return null if the file does not exist, is not a file, or is empty + if (!recordingFile.exists() || !recordingFile.isFile || recordingFile.length() == 0L) return null + return recordingFile + } + + fun stopRecording() { + recordingUseCase.stopRecording() + } + + fun executeVoiceToContent(file: File) { val site = selectedSiteRepository.getSelectedSite() ?: run { _uiState.postValue(VoiceToContentResult(isError = true)) return } + if (isVoiceToContentEnabled()) { + viewModelScope.launch(Dispatchers.IO) { + val result = jetpackAIStore.fetchJetpackAIAssistantFeature(site) + when (result) { + is JetpackAIAssistantFeatureResponse.Success -> { + _aiAssistantFeatureState.postValue(result.model) + startVoiceToContentFlow(site, file) + } + is JetpackAIAssistantFeatureResponse.Error -> { + _uiState.postValue(VoiceToContentResult(isError = true)) + } + } + } + } + } + + private fun startVoiceToContentFlow(site: SiteModel, file: File) { if (isVoiceToContentEnabled()) { viewModelScope.launch { - val result = voiceToContentUseCase.execute(site) + val result = voiceToContentUseCase.execute(site, file) _uiState.postValue(result) } } } } + diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt new file mode 100644 index 000000000000..7b6f73ad6d90 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/AudioRecorder.kt @@ -0,0 +1,204 @@ +package org.wordpress.android.util.audio + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Success +import org.wordpress.android.util.audio.IAudioRecorder.AudioRecorderResult.Error + +class AudioRecorder( + private val applicationContext: Context, + private val recordingStrategy: RecordingStrategy +) : IAudioRecorder { + private var onRecordingFinished: (AudioRecorderResult) -> Unit = {} + + private val storeInMemory = true + private val filePath by lazy { + if (storeInMemory) { + applicationContext.cacheDir.absolutePath + "/recording.mp4" + } else { + applicationContext.getExternalFilesDir(null)?.absolutePath + "/recording.mp4" + } + } + + private var recorder: MediaRecorder? = null + private var recordingJob: Job? = null + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private var isPausedRecording = false + + private val _recordingUpdates = MutableStateFlow(RecordingUpdate()) + private val recordingUpdates: StateFlow get() = _recordingUpdates.asStateFlow() + + private val _isRecording = MutableStateFlow(false) + val isRecording: StateFlow = _isRecording + + private val _isPaused = MutableStateFlow(false) + val isPaused: StateFlow = _isPaused + + @Suppress("DEPRECATION") + override fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) { + this.onRecordingFinished = onRecordingFinished + if (applicationContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + try { + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(applicationContext) + } else { + MediaRecorder() + }.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(filePath) + + prepare() + start() + startRecordingUpdates() + _isRecording.value = true + _isPaused.value = false + } + } catch (e: IOException) { + val errorMessage = "Error preparing MediaRecorder: ${e.message}" + Log.e(TAG, errorMessage) + onRecordingFinished(Error(errorMessage)) + } catch (e: IllegalStateException) { + val errorMessage = "Illegal state when starting recording: ${e.message}" + Log.e(TAG, errorMessage) + onRecordingFinished(Error(errorMessage)) + } catch (e: SecurityException) { + val errorMessage = "Security exception when starting recording: ${e.message}" + Log.e(TAG, errorMessage) + onRecordingFinished(Error(errorMessage)) + } + } else { + val errorMessage = "Permission to record audio not granted" + Log.e(TAG, errorMessage) + onRecordingFinished(Error(errorMessage)) + } + } + + override fun stopRecording() { + try { + recorder?.apply { + stop() + release() + } + } catch (e: IllegalStateException) { + Log.e(TAG, "Error stopping recording: ${e.message}") + } finally { + recorder = null + stopRecordingUpdates() + _isPaused.value = false + _isRecording.value = false + } + // return filePath + onRecordingFinished(Success(filePath)) + } + + override fun pauseRecording() { + try { + recorder?.pause() + _isPaused.value = true + stopRecordingUpdates() + } catch (e: IllegalStateException) { + Log.e(TAG, "Error pausing recording: ${e.message}") + } catch (e: UnsupportedOperationException) { + Log.e(TAG, "Pause not supported on this device: ${e.message}") + } + } + + override fun resumeRecording() { + if (isPausedRecording) { + coroutineScope.launch { + try { + delay(RESUME_DELAY) + recorder?.resume() + _isPaused.value = false + isPausedRecording = false + startRecordingUpdates() + } catch (e: IllegalStateException) { + Log.e(TAG, "Error resuming recording") + } + } + } + } + + override fun recordingUpdates(): Flow = recordingUpdates + + @Suppress("MagicNumber") + private fun startRecordingUpdates() { + recordingJob = coroutineScope.launch { + var elapsedTimeInSeconds = 0 + while (recorder != null) { + delay(RECORDING_UPDATE_INTERVAL) + elapsedTimeInSeconds += (RECORDING_UPDATE_INTERVAL / 1000).toInt() + val fileSize = File(filePath).length() + _recordingUpdates.value = RecordingUpdate( + elapsedTime = elapsedTimeInSeconds, + fileSize = fileSize, + fileSizeLimitExceeded = fileSize >= recordingStrategy.maxFileSize, + ) + + if ( maxFileSizeExceeded(fileSize) || maxDurationExceeded(elapsedTimeInSeconds) ) { + stopRecording() + break + } + } + } + } + + /** + * Checks if the recorded file size has exceeded the specified maximum file size. + * + * @param fileSize The current size of the recorded file in bytes. + * @return `true` if the file size has exceeded the maximum file size minus the threshold, `false` otherwise. + * If `recordingParams.maxFileSize` is set to `-1L`, this function always returns `false` indicating + * no limit. + */ + private fun maxFileSizeExceeded(fileSize: Long): Boolean = when { + recordingStrategy.maxFileSize == -1L -> false + else -> fileSize >= recordingStrategy.maxFileSize - FILE_SIZE_THRESHOLD + } + + /** + * Checks if the recording duration has exceeded the specified maximum duration. + * + * @param elapsedTimeInSeconds The elapsed recording time in seconds. + * @return `true` if the elapsed time has exceeded the maximum duration minus the threshold, `false` otherwise. + * If `recordingParams.maxDuration` is set to `-1`, this function always returns `false` indicating + * no limit. + */ + private fun maxDurationExceeded(elapsedTimeInSeconds: Int): Boolean = when { + recordingStrategy.maxDuration == -1 -> false + else -> elapsedTimeInSeconds >= recordingStrategy.maxDuration - DURATION_THRESHOLD + } + + private fun stopRecordingUpdates() { + recordingJob?.cancel() + } + + companion object { + private const val TAG = "AudioRecorder" + private const val RECORDING_UPDATE_INTERVAL = 1000L // in milliseconds + private const val RESUME_DELAY = 500L // in milliseconds + private const val FILE_SIZE_THRESHOLD = 100000L + private const val DURATION_THRESHOLD = 1 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt new file mode 100644 index 000000000000..73ab0ca30725 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/IAudioRecorder.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.util.audio + +import android.Manifest +import kotlinx.coroutines.flow.Flow + +interface IAudioRecorder { + fun startRecording(onRecordingFinished: (AudioRecorderResult) -> Unit) + fun stopRecording() + fun pauseRecording() + fun resumeRecording() + fun recordingUpdates(): Flow + + sealed class AudioRecorderResult { + data class Success(val recordingPath: String) : AudioRecorderResult() + data class Error(val errorMessage: String) : AudioRecorderResult() + } + + companion object { + val REQUIRED_RECORDING_PERMISSIONS = arrayOf( + Manifest.permission.RECORD_AUDIO + ) + } +} + + + diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingParams.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingParams.kt new file mode 100644 index 000000000000..37ee75037bb1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingParams.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.util.audio + +data class RecordingParams( + val maxDuration: Int, // seconds + val maxFileSize: Long, // bytes +) diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt new file mode 100644 index 000000000000..24339b323230 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingStrategy.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.util.audio + +import javax.inject.Qualifier + +@Suppress("MagicNumber") +sealed class RecordingStrategy { + abstract val maxFileSize: Long + abstract val maxDuration: Int + abstract val storeInMemory: Boolean + abstract val recordingFileName: String + + data class VoiceToContentRecordingStrategy( + override val maxFileSize: Long = 1000000L * 25, // 25MB + override val maxDuration: Int = 60 * 5, // 5 minutes + override val recordingFileName: String = "voice_recording.mp4", + override val storeInMemory: Boolean = true + ) : RecordingStrategy() +} + +// Declare here your custom annotation for each RecordingStrategy so it can be provided by Dagger +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class VoiceToContentStrategy + + diff --git a/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt new file mode 100644 index 000000000000..fbd7ceabce38 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/audio/RecordingUpdate.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.util.audio + +data class RecordingUpdate( + val elapsedTime: Int = 0, // in seconds + val fileSize: Long = 0L, // in bytes + val fileSizeLimitExceeded: Boolean = false +) diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index 8c278ea462a5..2a3c932cd678 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -208,7 +208,7 @@ class WPMainActivityViewModel @Inject constructor( if (voiceToContentFeatureUtils.isVoiceToContentEnabled() && hasFullAccessToContent(site)) { actionsList.add( CreateAction( - actionType = ActionType.CREATE_NEW_POST_FROM_AUDIO_AI, + actionType = ActionType.CREATE_NEW_POST_FROM_AUDIO, iconRes = R.drawable.ic_mic_white_24dp, labelRes = R.string.my_site_bottom_sheet_add_post_from_audio, onClickAction = ::onCreateActionClicked diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialog.kt b/WordPress/src/main/java/org/wordpress/android/widgets/AppReviewManager.kt similarity index 61% rename from WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialog.kt rename to WordPress/src/main/java/org/wordpress/android/widgets/AppReviewManager.kt index b57e67bccab9..4f9fb0794e66 100644 --- a/WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialog.kt +++ b/WordPress/src/main/java/org/wordpress/android/widgets/AppReviewManager.kt @@ -1,5 +1,6 @@ package org.wordpress.android.widgets +import android.app.Activity import android.app.Dialog import android.content.ActivityNotFoundException import android.content.Context @@ -12,23 +13,32 @@ import android.os.Bundle import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.play.core.review.ReviewManagerFactory import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.models.Note +import org.wordpress.android.ui.prefs.AppPrefs import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T +import org.wordpress.android.util.extensions.logException import java.util.Date import java.util.concurrent.TimeUnit -object AppRatingDialog { +object AppReviewManager { private const val PREF_NAME = "rate_wpandroid" private const val KEY_INSTALL_DATE = "rate_install_date" private const val KEY_LAUNCH_TIMES = "rate_launch_times" private const val KEY_OPT_OUT = "rate_opt_out" private const val KEY_ASK_LATER_DATE = "rate_ask_later_date" private const val KEY_INTERACTIONS = "rate_interactions" + private const val IN_APP_REVIEWS_SHOWN_DATE = "in_app_reviews_shown_date" + private const val DO_NOT_SHOW_IN_APP_REVIEWS_PROMPT = "do_not_show_in_app_reviews_prompt" + private const val TARGET_COUNT_POST_PUBLISHED = 2 + private const val TARGET_COUNT_NOTIFICATIONS = 10 // app must have been installed this long before the rating dialog will appear private const val CRITERIA_INSTALL_DAYS: Int = 7 + private val criteriaInstallMs = TimeUnit.DAYS.toMillis(CRITERIA_INSTALL_DAYS.toLong()) // app must have been launched this many times before the rating dialog will appear private const val CRITERIA_LAUNCH_TIMES: Int = 10 @@ -41,6 +51,8 @@ object AppRatingDialog { private var launchTimes = 0 private var interactions = 0 private var optOut = false + private var inAppReviewsShownDate = Date(0) + private var doNotShowInAppReviewsPrompt = false private lateinit var preferences: SharedPreferences @@ -66,13 +78,34 @@ object AppRatingDialog { optOut = preferences.getBoolean(KEY_OPT_OUT, false) installDate = Date(preferences.getLong(KEY_INSTALL_DATE, 0)) askLaterDate = Date(preferences.getLong(KEY_ASK_LATER_DATE, 0)) + + inAppReviewsShownDate = Date(preferences.getLong(IN_APP_REVIEWS_SHOWN_DATE, 0)) + doNotShowInAppReviewsPrompt = preferences.getBoolean(DO_NOT_SHOW_IN_APP_REVIEWS_PROMPT, false) + } + + fun launchInAppReviews(activity: Activity) { + AppLog.d(T.UTILS, "Launching in-app reviews prompt") + val manager = ReviewManagerFactory.create(activity) + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + val reviewInfo = task.result + val flow = manager.launchReviewFlow(activity, reviewInfo) + flow.addOnFailureListener { e -> + AppLog.e(T.UTILS, "Error launching google review API flow.", e) + } + } else { + task.logException() + } + } + + resetInAppReviewsCounters() } /** * Show the rate dialog if the criteria is satisfied. * @return true if shown, false otherwise. */ - fun showRateDialogIfNeeded(fragmentManger: FragmentManager): Boolean { return if (shouldShowRateDialog()) { showRateDialog(fragmentManger) @@ -94,6 +127,42 @@ object AppRatingDialog { } } + /** + * Called when a post is published. We use this to determine which users will see the in-app review prompt. + */ + fun onPostPublished() { + if (shouldShowInAppReviewsPrompt()) return + if (AppPrefs.getPublishedPostCount() < TARGET_COUNT_POST_PUBLISHED) { + AppPrefs.incrementPublishedPostCount() + AppLog.d(T.UTILS, "In-app reviews counter for published posts: ${AppPrefs.getPublishedPostCount()}") + } + } + + /** + * Called when a notification is received. We use this to determine which users will see the in-app review prompt. + */ + fun onNotificationReceived(note: Note) { + if (shouldShowInAppReviewsPrompt()) return + val shouldTrack = note.isUnread && (note.isLikeType || note.isCommentType || note.isFollowType) + if (shouldTrack && AppPrefs.getInAppReviewsNotificationCount() < TARGET_COUNT_NOTIFICATIONS) { + AppPrefs.incrementInAppReviewsNotificationCount() + AppLog.d(T.UTILS, "In-app reviews counter for notification: ${AppPrefs.getInAppReviewsNotificationCount()}") + } + } + + /** + * Check whether the in-app reviews prompt should be shown or not. + * @return true if the prompt should be shown + */ + fun shouldShowInAppReviewsPrompt(): Boolean { + val shouldWaitAfterLastShown = Date().time - inAppReviewsShownDate.time < criteriaInstallMs + val shouldWaitAfterAskLaterTapped = Date().time - askLaterDate.time < criteriaInstallMs + val publishedPostsGoal = AppPrefs.getPublishedPostCount() == TARGET_COUNT_POST_PUBLISHED + val notificationsGoal = AppPrefs.getInAppReviewsNotificationCount() == TARGET_COUNT_NOTIFICATIONS + return !doNotShowInAppReviewsPrompt && !shouldWaitAfterAskLaterTapped && !shouldWaitAfterLastShown && + (publishedPostsGoal || notificationsGoal) + } + /** * Check whether the rate dialog should be shown or not. * @return true if the dialog should be shown @@ -102,8 +171,7 @@ object AppRatingDialog { return if (optOut or (launchTimes < CRITERIA_LAUNCH_TIMES) or (interactions < CRITERIA_INTERACTIONS)) { false } else { - val thresholdMs = TimeUnit.DAYS.toMillis(CRITERIA_INSTALL_DAYS.toLong()) - Date().time - installDate.time >= thresholdMs && Date().time - askLaterDate.time >= thresholdMs + Date().time - installDate.time >= criteriaInstallMs && Date().time - askLaterDate.time >= criteriaInstallMs } } @@ -113,6 +181,8 @@ object AppRatingDialog { dialog = AppRatingDialog() dialog.show(fragmentManger, AppRatingDialog.TAG_APP_RATING_PROMPT_DIALOG) AnalyticsTracker.track(AnalyticsTracker.Stat.APP_REVIEWS_SAW_PROMPT) + + resetInAppReviewsCounters() } } @@ -141,14 +211,17 @@ object AppRatingDialog { Intent.ACTION_VIEW, Uri.parse( "http://play.google.com/store/apps/details?id=" + - requireActivity().packageName + requireActivity().packageName ) ) ) } - setOptOut(true) + setOptOut() AnalyticsTracker.track(AnalyticsTracker.Stat.APP_REVIEWS_RATED_APP) + + // Reset the published post counter of in-app reviews prompt flow. + AppPrefs.resetPublishedPostCount() } .setNeutralButton(R.string.app_rating_rate_later) { _, _ -> clearSharedPreferences() @@ -156,8 +229,10 @@ object AppRatingDialog { AnalyticsTracker.track(AnalyticsTracker.Stat.APP_REVIEWS_DECIDED_TO_RATE_LATER) } .setNegativeButton(R.string.app_rating_rate_never) { _, _ -> - setOptOut(true) + setOptOut() AnalyticsTracker.track(AnalyticsTracker.Stat.APP_REVIEWS_DECLINED_TO_RATE_APP) + + doNotShowInAppReviewsPromptAgain() } return builder.create() } @@ -178,13 +253,20 @@ object AppRatingDialog { } /** - * Set opt out flag - when true, the rate dialog will never be shown unless app data is cleared. + * Set opt out flag - the rate dialog will never be shown unless app data is cleared. */ - private fun setOptOut(optOut: Boolean) { + private fun setOptOut() { preferences.edit().putBoolean(KEY_OPT_OUT, optOut)?.apply() - this.optOut = optOut + this.optOut = true } + /** + * Set do not show in-app reviews prompt flag - the in-app reviews prompt will never be shown unless app data is + * cleared. + */ + private fun doNotShowInAppReviewsPromptAgain() = + preferences.edit().putBoolean(DO_NOT_SHOW_IN_APP_REVIEWS_PROMPT, optOut)?.apply() + /** * Store install date - retrieved from package manager if possible. */ @@ -207,4 +289,18 @@ object AppRatingDialog { val nextAskDate = System.currentTimeMillis() preferences.edit().putLong(KEY_ASK_LATER_DATE, nextAskDate)?.apply() } + + /** + * Store the date the in-app reviews prompt is attempted to launch. + */ + private fun storeInAppReviewsShownDate() { + inAppReviewsShownDate = Date(System.currentTimeMillis()) + preferences.edit().putLong(IN_APP_REVIEWS_SHOWN_DATE, inAppReviewsShownDate.time)?.apply() + } + + private fun resetInAppReviewsCounters() { + storeInAppReviewsShownDate() + AppPrefs.resetPublishedPostCount() + AppPrefs.resetInAppReviewsNotificationCount() + } } diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialogWrapper.kt b/WordPress/src/main/java/org/wordpress/android/widgets/AppReviewsManagerWrapper.kt similarity index 51% rename from WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialogWrapper.kt rename to WordPress/src/main/java/org/wordpress/android/widgets/AppReviewsManagerWrapper.kt index 827036fd86c6..6c8f2b4615f3 100644 --- a/WordPress/src/main/java/org/wordpress/android/widgets/AppRatingDialogWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/widgets/AppReviewsManagerWrapper.kt @@ -1,11 +1,13 @@ package org.wordpress.android.widgets import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.models.Note import javax.inject.Inject /** * Mockable wrapper created for testing purposes. */ -class AppRatingDialogWrapper @Inject constructor() { - fun incrementInteractions(tracker: AnalyticsTracker.Stat) = AppRatingDialog.incrementInteractions(tracker) +class AppReviewsManagerWrapper @Inject constructor() { + fun onNotificationReceived(note: Note) = AppReviewManager.onNotificationReceived(note) + fun incrementInteractions(tracker: AnalyticsTracker.Stat) = AppReviewManager.incrementInteractions(tracker) } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index fc1aad108df8..3ca5cf0a2a55 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4898,4 +4898,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> You can copy your post text in case your content is impacted. Copy error details to debug and share with support. Clear selected color Link label + + + Audio Recording Permission Required + To record audio, this app needs permission to access your microphone. You have previously denied this permission. Please enable the microphone permission in the app settings to use this feature. + diff --git a/WordPress/src/release/java/org/wordpress/android/WPWellSqlConfig.kt b/WordPress/src/release/java/org/wordpress/android/WPWellSqlConfig.kt new file mode 100644 index 000000000000..0c9b384f50b2 --- /dev/null +++ b/WordPress/src/release/java/org/wordpress/android/WPWellSqlConfig.kt @@ -0,0 +1,14 @@ +package org.wordpress.android + +import android.content.Context +import org.wordpress.android.fluxc.persistence.WellSqlConfig + +class WPWellSqlConfig(context: Context) : WellSqlConfig(context) { + /** + * Increase the cursor window size to 20MB for devices running API 28 and above. This should reduce the + * number of SQLiteBlobTooBigExceptions. Note that this is only called on API 28 and + * above since earlier versions don't allow adjusting the cursor window size. + */ + @Suppress("MagicNumber") + override fun getCursorWindowSize() = (1024L * 1024L * 20L) +} diff --git a/WordPress/src/release/java/org/wordpress/android/WellSqlInitializer.kt b/WordPress/src/release/java/org/wordpress/android/WellSqlInitializer.kt deleted file mode 100644 index d33fcb9d1de3..000000000000 --- a/WordPress/src/release/java/org/wordpress/android/WellSqlInitializer.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.wordpress.android - -import android.content.Context -import com.yarolegovich.wellsql.WellSql -import org.wordpress.android.fluxc.persistence.WellSqlConfig -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WellSqlInitializer @Inject constructor(private val context: Context) { - fun init() { - val wellSqlConfig = WellSqlConfig(context) - WellSql.init(wellSqlConfig) - } -} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt index 175e7a2df80a..d71d5e108d2a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt @@ -36,6 +36,7 @@ import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper import org.wordpress.android.util.EventBusWrapper import org.wordpress.android.util.NetworkUtilsWrapper import org.wordpress.android.util.ToastUtilsWrapper +import org.wordpress.android.widgets.AppReviewsManagerWrapper private const val REQUEST_BLOG_LISTENER_PARAM_POSITION = 2 @@ -72,6 +73,9 @@ class NotificationsListViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var appReviewsManagerWrapper: AppReviewsManagerWrapper + @Mock private lateinit var siteStore: SiteStore @@ -103,6 +107,7 @@ class NotificationsListViewModelTest : BaseUnitTest() { networkUtilsWrapper, toastUtilsWrapper, notificationsUtilsWrapper, + appReviewsManagerWrapper, appLogWrapper, siteStore, commentStore, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt deleted file mode 100644 index dc83739ec55d..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.wordpress.android.ui.review - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.junit.Before -import org.junit.Rule -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.eventToList -import org.wordpress.android.ui.prefs.AppPrefsWrapper -import kotlin.test.assertEquals - -@RunWith(MockitoJUnitRunner::class) -class ReviewViewModelTest { - @Rule - @JvmField - val rule = InstantTaskExecutorRule() - - @Mock - lateinit var appPrefsWrapper: AppPrefsWrapper - - private lateinit var viewModel: ReviewViewModel - - private lateinit var events: MutableList - - @Before - fun setup() { - viewModel = ReviewViewModel(appPrefsWrapper) - events = mutableListOf() - events = viewModel.launchReview.eventToList() - } - - @Test - fun onPublishingPost_whenPublishedCountIsLow_doNotLaunchInAppReviews() { - whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(false) - whenever(appPrefsWrapper.getPublishedPostCount()).thenReturn(ReviewViewModel.TARGET_COUNT_POST_PUBLISHED - 1) - - viewModel.onPublishingPost(true) - - assertEquals(events.size, 0) - } - - @Test - fun onPublishingPost_whenInAppReviewsAlreadyShown_doNotLaunchInAppReviews() { - whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(true) - - viewModel.onPublishingPost(true) - - assertEquals(events.size, 0) - } - - @Test - fun onPublishingPost_whenPublishedCountIsHigh_launchInAppReviews() { - whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(false) - whenever(appPrefsWrapper.getPublishedPostCount()).thenReturn(ReviewViewModel.TARGET_COUNT_POST_PUBLISHED) - - viewModel.onPublishingPost(true) - - // Verify `launchReview` is triggered. - assertEquals(Unit, events.last()) - } -} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt index 79cf10520b58..b9b4fb433897 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentFeatureUtilsTest.kt @@ -2,12 +2,17 @@ package org.wordpress.android.ui.voicetocontent import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue 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.fluxc.model.jetpackai.JetpackAIAssistantFeature +import org.wordpress.android.fluxc.model.jetpackai.Tier +import org.wordpress.android.fluxc.model.jetpackai.UsagePeriod import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.config.VoiceToContentFeatureConfig @@ -32,6 +37,7 @@ class VoiceToContentFeatureUtilsTest { // Arrange whenever(buildConfigWrapper.isJetpackApp).thenReturn(true) whenever(voiceToContentFeatureConfig.isEnabled()).thenReturn(true) + whenever(buildConfigWrapper.isDebug()).thenReturn(true) // Act val result = utils.isVoiceToContentEnabled() @@ -64,4 +70,111 @@ class VoiceToContentFeatureUtilsTest { // Assert assertEquals(false, result) } + + @Test + fun `when site requires an upgrade, then is not eligible for voiceToContent`() { + val feature = JetpackAIAssistantFeature( + hasFeature = false, + isOverLimit = false, + requestsCount = 0, + requestsLimit = 0, + usagePeriod = null, + siteRequireUpgrade = true, + upgradeType = "", + currentTier = null, + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + ) + assertFalse(utils.isEligibleForVoiceToContent(feature)) + } + + @Test + fun `when site does not require an upgrade, then is eligible for voiceToContent`() { + val feature = JetpackAIAssistantFeature( + hasFeature = false, + isOverLimit = false, + requestsCount = 0, + requestsLimit = 0, + usagePeriod = null, + siteRequireUpgrade = false, + upgradeType = "", + currentTier = null, + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + ) + assertTrue(utils.isEligibleForVoiceToContent(feature)) + } + + @Test + fun `when is free plan, then request limit is calculate for free plan`() { + val freePlanFeature = JetpackAIAssistantFeature( + hasFeature = false, + isOverLimit = false, + requestsCount = 50, + requestsLimit = 100, + usagePeriod = null, + siteRequireUpgrade = false, + upgradeType = "", + currentTier = Tier(JETPACK_AI_FREE, 0, 0, null), + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + ) + assertEquals(50, utils.getRequestLimit(freePlanFeature)) + + val freePlanFeatureExceed = freePlanFeature.copy(requestsCount = 150) + assertEquals(0, utils.getRequestLimit(freePlanFeatureExceed)) + } + + @Test + fun `when unlimited plan, then request limit is calculated for unlimited plan`() { + val unlimitedPlanFeature = JetpackAIAssistantFeature( + hasFeature = false, + isOverLimit = false, + requestsCount = 0, + requestsLimit = 0, + usagePeriod = null, + siteRequireUpgrade = false, + upgradeType = "", + currentTier = Tier("", 0, 1, null), + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + ) + assertEquals(Int.MAX_VALUE, utils.getRequestLimit(unlimitedPlanFeature)) + } + + @Test + fun `when limited plan, then request limit is calculated for limited plan`() { + val limitedPlanFeature = JetpackAIAssistantFeature( + hasFeature = false, + isOverLimit = false, + requestsCount = 0, + requestsLimit = 0, + usagePeriod = UsagePeriod("2024-01-01", "2024-02-01", 100), + siteRequireUpgrade = false, + upgradeType = "", + currentTier = Tier("", 200, 0, null), + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + ) + assertEquals(100, utils.getRequestLimit(limitedPlanFeature)) + + val limitedPlanFeatureExceed = limitedPlanFeature.copy( + usagePeriod = UsagePeriod("2024-01-01", "2024-02-01", 250) + ) + assertEquals(0, utils.getRequestLimit(limitedPlanFeatureExceed)) + } + + companion object { + private const val JETPACK_AI_FREE = "jetpack_ai_free" + } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt index 9bcbfb120119..6e56b26950dd 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/voicetocontent/VoiceToContentViewModelTest.kt @@ -1,18 +1,24 @@ package org.wordpress.android.ui.voicetocontent import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat 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.any import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.jetpackai.JetpackAIStore import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.util.audio.RecordingUpdate +import java.io.File @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) @@ -23,19 +29,46 @@ class VoiceToContentViewModelTest : BaseUnitTest() { @Mock lateinit var voiceToContentUseCase: VoiceToContentUseCase + @Mock + lateinit var recordingUseCase: RecordingUseCase + @Mock lateinit var selectedSiteRepository: SelectedSiteRepository + @Mock + lateinit var jetpackAIStore: JetpackAIStore + private lateinit var viewModel: VoiceToContentViewModel private lateinit var uiState: MutableList + + /* private val jetpackAIAssistantFeature = JetpackAIAssistantFeature( + hasFeature = true, + isOverLimit = false, + requestsCount = 0, + requestsLimit = 0, + usagePeriod = null, + siteRequireUpgrade = true, + upgradeType = "upgradeType", + currentTier = null, + nextTier = null, + tierPlans = emptyList(), + tierPlansEnabled = false, + costs = null + )*/ + @Before fun setup() { + // Mock the recording updates to return a non-null flow before ViewModel instantiation + whenever(recordingUseCase.recordingUpdates()).thenReturn(createRecordingUpdateFlow()) + viewModel = VoiceToContentViewModel( testDispatcher(), voiceToContentFeatureUtils, voiceToContentUseCase, - selectedSiteRepository + selectedSiteRepository, + jetpackAIStore, + recordingUseCase ) uiState = mutableListOf() @@ -46,35 +79,54 @@ class VoiceToContentViewModelTest : BaseUnitTest() { } } + // Helper function to create a consistent flow + private fun createRecordingUpdateFlow() = flow { + emit(RecordingUpdate(0, 0, false)) + } + @Test fun `when site is null, then execute posts error state `() = test { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) - - viewModel.execute() + val dummyFile = File("dummy_path") + viewModel.executeVoiceToContent(dummyFile) val expectedState = VoiceToContentResult(isError = true) assertThat(uiState.first()).isEqualTo(expectedState) } - @Test + /* @Test fun `when voice to content is enabled, then execute invokes use case `() = test { val site = SiteModel().apply { id = 1 } + val dummyFile = File("dummy_path") + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) whenever(voiceToContentFeatureUtils.isVoiceToContentEnabled()).thenReturn(true) + whenever(jetpackAIStore.fetchJetpackAIAssistantFeature(site)) + .thenReturn(JetpackAIAssistantFeatureResponse.Success(jetpackAIAssistantFeature)) - viewModel.execute() + viewModel.executeVoiceToContent(dummyFile) - verify(voiceToContentUseCase).execute(site) - } + verify(voiceToContentUseCase).execute(site, dummyFile) + }*/ @Test - fun `when voice to content is disabled, then execute does not invoke use case `() = test { + fun `when voice to content is disabled, then executeVoiceToContent does not invoke use case`() = runTest { val site = SiteModel().apply { id = 1 } whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) whenever(voiceToContentFeatureUtils.isVoiceToContentEnabled()).thenReturn(false) + val dummyFile = File("dummy_path") - viewModel.execute() + viewModel.executeVoiceToContent(dummyFile) verifyNoInteractions(voiceToContentUseCase) } + + @Test + fun `when startRecording is called, then recordingUseCase starts recording`() { + viewModel.startRecording() + + verify(recordingUseCase).startRecording(any()) + } } + + diff --git a/build.gradle b/build.gradle index a6dfe5af3df8..07b9bdcd3e0d 100644 --- a/build.gradle +++ b/build.gradle @@ -15,17 +15,17 @@ plugins { ext { minSdkVersion = 24 compileSdkVersion = 34 - targetSdkVersion = 33 + targetSdkVersion = 34 } ext { // libs automatticAboutVersion = '1.4.0' automatticRestVersion = '1.0.8' - automatticTracksVersion = '5.0.0' - gutenbergMobileVersion = 'v1.119.0' + automatticTracksVersion = '5.1.0' + gutenbergMobileVersion = 'v1.120.0-alpha1' wordPressAztecVersion = 'v2.1.3' - wordPressFluxCVersion = '2.81.0' + wordPressFluxCVersion = 'trunk-8b7eeade00f33c5b4296722fb3854b3a32e06ad8' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0' diff --git a/docs/test_instructions_per_dependency_update.md b/docs/test_instructions_per_dependency_update.md index 7c373357c0cc..6071e687cf58 100644 --- a/docs/test_instructions_per_dependency_update.md +++ b/docs/test_instructions_per_dependency_update.md @@ -426,7 +426,7 @@ Step.3: 1. In app reviews - Perform a clean install. -- Publish three (`ReviewViewModel.TARGET_COUNT_POST_PUBLISHED + 1`) new posts or stories. +- Publish three (`AppReviewManager.TARGET_COUNT_POST_PUBLISHED + 1`) new posts or stories. - Verify that there are no crashes. diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index 699f1078283e..14cee38b4b75 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -1134,7 +1134,7 @@ public enum Stat { IN_APP_UPDATE_SHOWN, IN_APP_UPDATE_DISMISSED, IN_APP_UPDATE_ACCEPTED, - IN_APP_UPDATE_COMPLETED_WITH_APP_RESTART; + IN_APP_UPDATE_COMPLETED_WITH_APP_RESTART_BY_USER; /* * Please set the event name in the enum only if the new Stat's name in lower case does not match it. diff --git a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java index 3084e5ad16ee..c3242ad2d50c 100644 --- a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java +++ b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java @@ -6,6 +6,7 @@ import android.view.ViewGroup; import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Consumer; import androidx.core.util.Pair; @@ -311,6 +312,10 @@ public void onRedoPressed() { mWPAndroidGlueCode.onRedoPressed(); } + public void onContentUpdate(@NonNull String content) { + mWPAndroidGlueCode.onContentUpdate(content); + } + public void updateCapabilities(GutenbergPropsBuilder gutenbergPropsBuilder) { // We want to make sure that activity isn't null // as it can make this crash to happen: https://github.com/wordpress-mobile/WordPress-Android/issues/13248 diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000000..1a19f259875d --- /dev/null +++ b/renovate.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "enabled": false, + "packagePatterns": [ + "*" + ] + }, + { + "enabled": true, + "matchDepPatterns": [ + "automattic|wpmreleasetoolkit|dangermattic" + ], + "separateMajorMinor": false + }, + { + "enabled": true, + "enabledManagers": [ + "gradle" + ], + "matchDepPatterns": [ + "automattic|wordpress|gravatar" + ], + "separateMajorMinor": false, + "versioning": "semver-coerced" + } + ] +}