diff options
11 files changed, 335 insertions, 37 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 843ae809..9b4582df 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -303,9 +303,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + previewViewModel.init( + mChooserRequest.getTargetIntent(), + getIntent(), + /*additionalContentUri = */ null, + /*focusedItemIdx = */ 0, + /*isPayloadTogglingEnabled = */ false); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), mChooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), createChooserActionFactory(), diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 3b20a45c..21c909ea 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -17,14 +17,22 @@ package com.android.intentresolver.contentpreview import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel /** A contract for the preview view model. Added for testing. */ abstract class BasePreviewViewModel : ViewModel() { - @MainThread abstract fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider - - abstract val imageLoader: ImageLoader - + @get:MainThread abstract val previewDataProvider: PreviewDataProvider + @get:MainThread abstract val imageLoader: ImageLoader abstract val payloadToggleInteractor: PayloadToggleInteractor? + + @MainThread + abstract fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) } diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index dbf27a88..7ff3b49e 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -16,10 +16,22 @@ package com.android.intentresolver.contentpreview +import android.content.ContentInterface +import android.content.Intent import android.database.Cursor +import android.database.MatrixCursor import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal import android.util.Log import android.util.SparseArray +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope + +// TODO: replace with the new API AdditionalContentContract$Columns#URI +private const val ColumnUri = "uri" +// TODO: replace with the new API AdditionalContentContract$CursorExtraKeys#POSITION +private const val ExtraPosition = "position" private const val TAG = ContentPreviewUi.TAG @@ -98,4 +110,38 @@ class CursorUriReader( override fun close() { cursor.close() } + + companion object { + suspend fun createCursorReader( + contentResolver: ContentInterface, + uri: Uri, + chooserIntent: Intent + ): CursorUriReader { + val cancellationSignal = CancellationSignal() + val cursor = + try { + coroutineScope { + runCatching { + contentResolver.query( + uri, + arrayOf(ColumnUri), + Bundle().apply { + putParcelable(Intent.EXTRA_INTENT, chooserIntent) + }, + cancellationSignal + ) + } + .getOrNull() + ?: MatrixCursor(arrayOf(ColumnUri)) + } + } catch (e: CancellationException) { + cancellationSignal.cancel() + throw e + } + return CursorUriReader(cursor, cursor.extras?.getInt(ExtraPosition, 0) ?: 0, 128) { + // TODO: check that authority is case-sensitive for resolution reasons + it.authority != uri.authority + } + } + } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 38918d79..659f7dc9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -100,6 +100,9 @@ constructor( open val uriCount: Int get() = records.size + val uris: List<Uri> + get() = records.map { it.uri } + /** * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and * [FileInfo.previewUri] set (a data projection tailored for the image preview UI). diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 77cf0ac9..7369fa0f 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,7 +17,9 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.ContentResolver import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -26,46 +28,90 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R import com.android.intentresolver.inject.Background -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus -/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -@HiltViewModel -class PreviewViewModel -@Inject -constructor( - private val application: Application, +/** A view model for the preview logic */ +class PreviewViewModel( + private val contentResolver: ContentResolver, + // TODO: inject ImageLoader instead + private val thumbnailSize: Int, @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { - private var previewDataProvider: PreviewDataProvider? = null + private var targetIntent: Intent? = null + private var chooserIntent: Intent? = null + private var additionalContentUri: Uri? = null + private var focusedItemIdx: Int = 0 + private var isPayloadTogglingEnabled = false - @MainThread - override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = - previewDataProvider - ?: PreviewDataProvider( - viewModelScope + dispatcher, - targetIntent, - application.contentResolver - ) - .also { previewDataProvider = it } + override val previewDataProvider by lazy { + val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } + PreviewDataProvider(viewModelScope + dispatcher, targetIntent, contentResolver) + } override val imageLoader by lazy { ImagePreviewImageLoader( viewModelScope + dispatcher, - thumbnailSize = - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ), - application.contentResolver, + thumbnailSize, + contentResolver, cacheSize = 16 ) } override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - null // TODO: initialize PayloadToggleInteractor() + val targetIntent = requireNotNull(targetIntent) { "Not initialized" } + // TODO: replace with flags injection + if (!isPayloadTogglingEnabled) return@lazy null + createPayloadToggleInteractor( + additionalContentUri ?: return@lazy null, + targetIntent, + chooserIntent ?: return@lazy null, + ) + .apply { start() } + } + + // TODO: make the view model injectable and inject these dependencies instead + @MainThread + override fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) { + if (this.targetIntent != null) return + this.targetIntent = targetIntent + this.chooserIntent = chooserIntent + this.additionalContentUri = additionalContentUri + this.focusedItemIdx = focusedItemIdx + this.isPayloadTogglingEnabled = isPayloadTogglingEnabled + } + + private fun createPayloadToggleInteractor( + contentProviderUri: Uri, + targetIntent: Intent, + chooserIntent: Intent, + ): PayloadToggleInteractor { + return PayloadToggleInteractor( + // TODO: update PayloadToggleInteractor to support multiple threads + viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(), + previewDataProvider.uris, + maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)), + DefaultMimeTypeClassifier, + { + CursorUriReader.createCursorReader( + contentResolver, + contentProviderUri, + chooserIntent + ) + }, + UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), + TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), + SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) + ) } companion object { @@ -75,7 +121,16 @@ constructor( override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras - ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T + ): T { + val application: Application = checkNotNull(extras[APPLICATION_KEY]) + return PreviewViewModel( + application.contentResolver, + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ) + ) + as T + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt index 99cfc0f8..d7e04920 100644 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview +import android.content.ClipData import android.content.ClipDescription.compareMimeTypes import android.content.Intent import android.content.Intent.ACTION_SEND @@ -45,6 +46,12 @@ class TargetIntentModifier<Item>( } else { putParcelableArrayListExtra(EXTRA_STREAM, uris) } + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt new file mode 100644 index 00000000..784cefa0 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentResolver +import android.net.Uri + +// TODO: share this logic with PreviewDataProvider +class UriMetadataReader( + private val contentResolver: ContentResolver, + private val mimeTypeClassifier: MimeTypeClassifier, +) : (Uri) -> FileInfo { + fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .apply { + runCatching { + withMimeType(contentResolver.getType(uri)) + if (mimeTypeClassifier.isImageType(mimeType)) { + withPreviewUri(uri) + } + } + } + .build() + + override fun invoke(uri: Uri): FileInfo = getMetadata(uri) +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 30845818..2ffd31d8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -268,6 +268,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public ActivityLaunch mActivityLaunch; @Inject public FeatureFlags mFeatureFlags; + @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @Inject @ImageEditor public Optional<ComponentName> mImageEditor; @@ -480,9 +481,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + previewViewModel.init( + chooserRequest.getTargetIntent(), + getIntent(), + chooserRequest.getAdditionalContentUri(), + chooserRequest.getFocusedItemPosition(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), chooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), createChooserActionFactory(), diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index 998c0802..b352f360 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -17,28 +17,42 @@ package com.android.intentresolver import android.content.Intent +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.contentpreview.BasePreviewViewModel import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.PayloadToggleInteractor -import com.android.intentresolver.contentpreview.PreviewDataProvider /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( private val viewModel: BasePreviewViewModel, - private val imageLoaderDelegate: ImageLoader?, + override val imageLoader: ImageLoader, ) : BasePreviewViewModel() { - override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = - viewModel.createOrReuseProvider(targetIntent) - override val imageLoader: ImageLoader - get() = imageLoaderDelegate ?: viewModel.imageLoader + override val previewDataProvider + get() = viewModel.previewDataProvider override val payloadToggleInteractor: PayloadToggleInteractor? get() = viewModel.payloadToggleInteractor + override fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) { + viewModel.init( + targetIntent, + chooserIntent, + additionalContentUri, + focusedItemIdx, + isPayloadTogglingEnabled + ) + } + companion object { fun wrap( factory: ViewModelProvider.Factory, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt index d53a9af7..cd1c503a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt @@ -16,13 +16,24 @@ package com.android.intentresolver.contentpreview +import android.content.ContentInterface +import android.content.Intent import android.database.MatrixCursor import android.net.Uri import android.util.SparseArray +import com.android.intentresolver.any +import com.android.intentresolver.anyOrNull +import com.android.intentresolver.mock +import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Test class CursorUriReaderTest { + private val scope = TestScope() + @Test fun readEmptyCursor() { val testSubject = @@ -84,6 +95,26 @@ class CursorUriReaderTest { // TODO: add tests with filtered-out items // TODO: add tests with a failing cursor + + @Test + fun testFailingQueryCall_emptyCursorCreated() = + scope.runTest { + val contentResolver = + mock<ContentInterface> { + whenever(query(any(), any(), anyOrNull(), any())) + .thenThrow(SecurityException("Test exception")) + } + val cursorReader = + CursorUriReader.createCursorReader( + contentResolver, + Uri.parse("content://auth"), + Intent(Intent.ACTION_CHOOSER) + ) + + assertWithMessage("Empty cursor reader is expected") + .that(cursorReader.count) + .isEqualTo(0) + } } private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt new file mode 100644 index 00000000..1a59a930 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewViewModelTest { + @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val targetIntent = Intent(Intent.ACTION_SEND) + private val chooserIntent = Intent.createChooser(targetIntent, null) + private val additionalContentUri = Uri.parse("content://org.pkg.content") + + @Test + fun featureFlagDisabled_noPayloadToggleInteractorCreated() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init( + targetIntent, + chooserIntent, + additionalContentUri, + focusedItemIdx = 0, + isPayloadTogglingEnabled = false + ) + } + + assertThat(testSubject.payloadToggleInteractor).isNull() + } + + @Test + fun noAdditionalContentUri_noPayloadToggleInteractorCreated() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init( + targetIntent, + chooserIntent, + additionalContentUri = null, + focusedItemIdx = 0, + true + ) + } + + assertThat(testSubject.payloadToggleInteractor).isNull() + } + + @Test + fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true) + } + + assertThat(testSubject.payloadToggleInteractor).isNotNull() + } +} |