diff options
Diffstat (limited to 'tests/shared')
11 files changed, 1209 insertions, 0 deletions
diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp new file mode 100644 index 0000000..55188ee --- /dev/null +++ b/tests/shared/Android.bp @@ -0,0 +1,37 @@ +// +// Copyright (C) 2023 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "IntentResolver-tests-shared", + srcs: [ + "src/**/*.java", + "src/**/*.kt", + ], + libs: [ + "android.test.mock", + "framework", + ], + static_libs: [ + "hamcrest", + "IntentResolver-core", + "mockito-target-minus-junit4", + "truth" + ], +} diff --git a/tests/shared/src/com/android/intentresolver/MatcherUtils.java b/tests/shared/src/com/android/intentresolver/MatcherUtils.java new file mode 100644 index 0000000..97cc698 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/MatcherUtils.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 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; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +/** + * Utils for helping with more customized matching options, for example matching the first + * occurrence of a set criteria. + */ +public class MatcherUtils { + + /** + * Returns a {@link Matcher} which only matches the first occurrence of a set criteria. + */ + public static <T> Matcher<T> first(final Matcher<T> matcher) { + return new BaseMatcher<T>() { + boolean isFirstMatch = true; + + @Override + public boolean matches(final Object item) { + if (isFirstMatch && matcher.matches(item)) { + isFirstMatch = false; + return true; + } + return false; + } + + @Override + public void describeTo(final Description description) { + description.appendText("Returns the first matching item"); + } + }; + } +} diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt new file mode 100644 index 0000000..db9fbd9 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2022 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 + +/** + * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects + * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not + * be null"). To fix this, we can use methods that modify the return type to be nullable. This + * causes Kotlin to skip the null checks. + * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt + */ +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatcher +import org.mockito.ArgumentMatchers +import org.mockito.MockSettings +import org.mockito.Mockito +import org.mockito.stubbing.Answer +import org.mockito.stubbing.OngoingStubbing +import org.mockito.stubbing.Stubber + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> eq(obj: T): T = Mockito.eq<T>(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> any(type: Class<T>): T = Mockito.any<T>(type) +inline fun <reified T> any(): T = any(T::class.java) + +/** + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) + +/** + * Kotlin type-inferred version of Mockito.nullable() + */ +inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java) + +/** + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException + * when null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = + ArgumentCaptor.forClass(T::class.java) + +/** + * Helper function for creating new mocks, without the need to pass in a [Class] instance. + * + * Generic T is nullable because implicitly bounded by Any?. + * + * @param apply builder function to simplify stub configuration by improving type inference. + */ +inline fun <reified T : Any> mock( + mockSettings: MockSettings = Mockito.withSettings(), + apply: T.() -> Unit = {} +): T = Mockito.mock(T::class.java, mockSettings).apply(apply) + +/** + * Helper function for stubbing methods without the need to use backticks. + * + * @see Mockito.when + */ +fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) + +/** + * Helper function for stubbing methods without the need to use backticks. + */ +fun <T> Stubber.whenever(mock: T): T = `when`(mock) + +/** + * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when + * kotlin tests are mocking kotlin objects and the methods take non-null parameters: + * + * java.lang.NullPointerException: capture() must not be null + */ +class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) { + private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz) + fun capture(): T = wrapped.capture() + val value: T + get() = wrapped.value + val allValues: List<T> + get() = wrapped.allValues +} + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> = + KotlinArgumentCaptor(T::class.java) + +/** + * Helper function for creating and using a single-use ArgumentCaptor in kotlin. + * + * val captor = argumentCaptor<Foo>() + * verify(...).someMethod(captor.capture()) + * val captured = captor.value + * + * becomes: + * + * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } + * + * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + */ +inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T = + kotlinArgumentCaptor<T>().apply { block() }.value + +/** + * Variant of [withArgCaptor] for capturing multiple arguments. + * + * val captor = argumentCaptor<Foo>() + * verify(...).someMethod(captor.capture()) + * val captured: List<Foo> = captor.allValues + * + * becomes: + * + * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + */ +inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = + kotlinArgumentCaptor<T>().apply { block() }.allValues + +inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true }) + +/** + * Intended as a default Answer for a mock to prevent dependence on defaults. + * + * Use as: + * ``` + * val context = mock<Context>(withSettings() + * .defaultAnswer(THROWS_EXCEPTION)) + * ``` + * + * To avoid triggering the exception during stubbing, must ONLY use one of the doXXX() methods, such + * as: + * * [doAnswer][Mockito.doAnswer] + * * [doCallRealMethod][Mockito.doCallRealMethod] + * * [doNothing][Mockito.doNothing] + * * [doReturn][Mockito.doReturn] + * * [doThrow][Mockito.doThrow] + */ +val THROWS_EXCEPTION = Answer { error("Unstubbed behavior was accessed.") } diff --git a/tests/shared/src/com/android/intentresolver/ResolverDataProvider.java b/tests/shared/src/com/android/intentresolver/ResolverDataProvider.java new file mode 100644 index 0000000..db10994 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/ResolverDataProvider.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2008 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; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.os.UserHandle; +import android.test.mock.MockContext; +import android.test.mock.MockPackageManager; +import android.test.mock.MockResources; + +import androidx.annotation.NonNull; + +/** + * Utility class used by resolver tests to create mock data + */ +public class ResolverDataProvider { + + static private int USER_SOMEONE_ELSE = 10; + + static ResolvedComponentInfo createResolvedComponentInfo(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, UserHandle.USER_CURRENT)); + } + + public static ResolvedComponentInfo createResolvedComponentInfo(int i, + UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, UserHandle.USER_CURRENT, resolvedForUser)); + } + + static ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent) { + return new ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT)); + } + + public static ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent, UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT, resolvedForUser)); + } + + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, USER_SOMEONE_ELSE)); + } + + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, USER_SOMEONE_ELSE, resolvedForUser)); + } + + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, userId)); + } + + public static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + int userId, UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, userId, resolvedForUser)); + } + + public static ComponentName createComponentName(int i) { + final String name = "component" + i; + return new ComponentName("foo.bar." + name, name); + } + + public static ResolveInfo createResolveInfo(int i, int userId) { + return createResolveInfo(i, userId, UserHandle.of(userId)); + } + + public static ResolveInfo createResolveInfo(int i, int userId, UserHandle resolvedForUser) { + return createResolveInfo(createActivityInfo(i), userId, resolvedForUser); + } + + public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { + return createResolveInfo(componentName, userId, UserHandle.of(userId)); + } + + public static ResolveInfo createResolveInfo( + ComponentName componentName, int userId, UserHandle resolvedForUser) { + return createResolveInfo(createActivityInfo(componentName), userId, resolvedForUser); + } + + public static ResolveInfo createResolveInfo( + ActivityInfo activityInfo, int userId, UserHandle resolvedForUser) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = activityInfo; + resolveInfo.targetUserId = userId; + resolveInfo.userHandle = resolvedForUser; + return resolveInfo; + } + + static ActivityInfo createActivityInfo(int i) { + ActivityInfo ai = new ActivityInfo(); + ai.name = "activity_name" + i; + ai.packageName = "foo_bar" + i; + ai.enabled = true; + ai.exported = true; + ai.permission = null; + ai.applicationInfo = createApplicationInfo(); + return ai; + } + + static ActivityInfo createActivityInfo(ComponentName componentName) { + ActivityInfo ai = new ActivityInfo(); + ai.name = componentName.getClassName(); + ai.packageName = componentName.getPackageName(); + ai.enabled = true; + ai.exported = true; + ai.permission = null; + ai.applicationInfo = createApplicationInfo(); + ai.applicationInfo.packageName = componentName.getPackageName(); + return ai; + } + + static ApplicationInfo createApplicationInfo() { + ApplicationInfo ai = new ApplicationInfo(); + ai.name = "app_name"; + ai.packageName = "foo.bar"; + ai.enabled = true; + return ai; + } + + static class PackageManagerMockedInfo { + public Context ctx; + public ApplicationInfo appInfo; + public ActivityInfo activityInfo; + public ResolveInfo resolveInfo; + public String setAppLabel; + public String setActivityLabel; + public String setResolveInfoLabel; + } + + /** Create a {@link PackageManagerMockedInfo} with all distinct labels. */ + static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) { + return createPackageManagerMockedInfo( + hasOverridePermission, "app_label", "activity_label", "resolve_info_label"); + } + + static PackageManagerMockedInfo createPackageManagerMockedInfo( + boolean hasOverridePermission, + String appLabel, + String activityLabel, + String resolveInfoLabel) { + MockContext ctx = new MockContext() { + @Override + public PackageManager getPackageManager() { + return new MockPackageManager() { + @Override + public int checkPermission(String permName, String pkgName) { + if (hasOverridePermission) return PERMISSION_GRANTED; + return PERMISSION_DENIED; + } + }; + } + + @Override + public Resources getResources() { + return new MockResources() { + @NonNull + @Override + public String getString(int id) throws NotFoundException { + if (id == 1) return appLabel; + if (id == 2) return activityLabel; + if (id == 3) return resolveInfoLabel; + throw new NotFoundException(); + } + }; + } + }; + + ApplicationInfo appInfo = new ApplicationInfo() { + @NonNull + @Override + public CharSequence loadLabel(@NonNull PackageManager pm) { + return appLabel; + } + }; + appInfo.labelRes = 1; + + ActivityInfo activityInfo = new ActivityInfo() { + @NonNull + @Override + public CharSequence loadLabel(@NonNull PackageManager pm) { + return activityLabel; + } + }; + activityInfo.labelRes = 2; + activityInfo.applicationInfo = appInfo; + + ResolveInfo resolveInfo = new ResolveInfo() { + @NonNull + @Override + public CharSequence loadLabel(@NonNull PackageManager pm) { + return resolveInfoLabel; + } + }; + resolveInfo.activityInfo = activityInfo; + resolveInfo.resolvePackageName = "super.fake.packagename"; + resolveInfo.labelRes = 3; + + PackageManagerMockedInfo mockedInfo = new PackageManagerMockedInfo(); + mockedInfo.activityInfo = activityInfo; + mockedInfo.appInfo = appInfo; + mockedInfo.ctx = ctx; + mockedInfo.resolveInfo = resolveInfo; + mockedInfo.setAppLabel = appLabel; + mockedInfo.setActivityLabel = activityLabel; + mockedInfo.setResolveInfoLabel = resolveInfoLabel; + + return mockedInfo; + } + + static Intent createResolverIntent(int i) { + return new Intent("intentAction" + i); + } +} diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt new file mode 100644 index 0000000..888fc16 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2023 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 + +import android.content.Intent +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.PreviewDataProvider + +/** A test content preview model that supports image loader override. */ +class TestContentPreviewViewModel( + private val viewModel: BasePreviewViewModel, + private val imageLoader: ImageLoader? = null, +) : BasePreviewViewModel() { + override fun createOrReuseProvider( + targetIntent: Intent + ): PreviewDataProvider = viewModel.createOrReuseProvider(targetIntent) + + override fun createOrReuseImageLoader(): ImageLoader = + imageLoader ?: viewModel.createOrReuseImageLoader() + + companion object { + fun wrap( + factory: ViewModelProvider.Factory, + imageLoader: ImageLoader?, + ): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create( + modelClass: Class<T>, + extras: CreationExtras + ): T { + return TestContentPreviewViewModel( + factory.create(modelClass, extras) as BasePreviewViewModel, + imageLoader, + ) as T + } + } + } +} diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt new file mode 100644 index 0000000..f0203bb --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 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 + +import android.graphics.Bitmap +import android.net.Uri +import com.android.intentresolver.contentpreview.ImageLoader +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope + +class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader { + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callback.accept(bitmaps[uri]) + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] + + override fun prePopulate(uris: List<Uri>) = Unit +} diff --git a/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt new file mode 100644 index 0000000..9ed47db --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2023 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.logging + +import android.net.Uri +import android.util.HashedStringCache +import android.util.Log +import com.android.internal.logging.InstanceId +import javax.inject.Inject + +private const val TAG = "EventLog" +private const val LOG = true + +/** A fake EventLog. */ +class FakeEventLog @Inject constructor(private val instanceId: InstanceId) : EventLog { + + var chooserActivityShown: ChooserActivityShown? = null + var actionSelected: ActionSelected? = null + var customActionSelected: CustomActionSelected? = null + var actionShareWithPreview: ActionShareWithPreview? = null + val shareTargetSelected: MutableList<ShareTargetSelected> = mutableListOf() + + private fun log(message: () -> Any?) { + if (LOG) { + Log.d(TAG, "[%04x] ".format(instanceId.id) + message()) + } + } + + override fun logChooserActivityShown( + isWorkProfile: Boolean, + targetMimeType: String?, + systemCost: Long + ) { + chooserActivityShown = ChooserActivityShown(isWorkProfile, targetMimeType, systemCost) + log { chooserActivityShown } + } + + override fun logShareStarted( + packageName: String?, + mimeType: String?, + appProvidedDirect: Int, + appProvidedApp: Int, + isWorkprofile: Boolean, + previewType: Int, + intent: String?, + customActionCount: Int, + modifyShareActionProvided: Boolean + ) { + log { + ShareStarted( + packageName, + mimeType, + appProvidedDirect, + appProvidedApp, + isWorkprofile, + previewType, + intent, + customActionCount, + modifyShareActionProvided + ) + } + } + + override fun logCustomActionSelected(positionPicked: Int) { + customActionSelected = CustomActionSelected(positionPicked) + log { "logCustomActionSelected(positionPicked=$positionPicked)" } + } + + override fun logShareTargetSelected( + targetType: Int, + packageName: String?, + positionPicked: Int, + directTargetAlsoRanked: Int, + numCallerProvided: Int, + directTargetHashed: HashedStringCache.HashResult?, + isPinned: Boolean, + successfullySelected: Boolean, + selectionCost: Long + ) { + shareTargetSelected.add( + ShareTargetSelected( + targetType, + packageName, + positionPicked, + directTargetAlsoRanked, + numCallerProvided, + directTargetHashed, + isPinned, + successfullySelected, + selectionCost + ) + ) + log { shareTargetSelected.last() } + shareTargetSelected.limitSize(10) + } + + private fun MutableList<*>.limitSize(n: Int) { + while (size > n) { + removeFirst() + } + } + + override fun logDirectShareTargetReceived(category: Int, latency: Int) { + log { "logDirectShareTargetReceived(category=$category, latency=$latency)" } + } + + override fun logActionShareWithPreview(previewType: Int) { + actionShareWithPreview = ActionShareWithPreview(previewType) + log { actionShareWithPreview } + } + + override fun logActionSelected(targetType: Int) { + actionSelected = ActionSelected(targetType) + log { actionSelected } + } + + override fun logContentPreviewWarning(uri: Uri?) { + log { "logContentPreviewWarning(uri=$uri)" } + } + + override fun logSharesheetTriggered() { + log { "logSharesheetTriggered()" } + } + + override fun logSharesheetAppLoadComplete() { + log { "logSharesheetAppLoadComplete()" } + } + + override fun logSharesheetDirectLoadComplete() { + log { "logSharesheetAppLoadComplete()" } + } + + override fun logSharesheetDirectLoadTimeout() { + log { "logSharesheetDirectLoadTimeout()" } + } + + override fun logSharesheetProfileChanged() { + log { "logSharesheetProfileChanged()" } + } + + override fun logSharesheetExpansionChanged(isCollapsed: Boolean) { + log { "logSharesheetExpansionChanged(isCollapsed=$isCollapsed)" } + } + + override fun logSharesheetAppShareRankingTimeout() { + log { "logSharesheetAppShareRankingTimeout()" } + } + + override fun logSharesheetEmptyDirectShareRow() { + log { "logSharesheetEmptyDirectShareRow()" } + } + + data class ActionSelected(val targetType: Int) + data class CustomActionSelected(val positionPicked: Int) + data class ActionShareWithPreview(val previewType: Int) + data class ChooserActivityShown( + val isWorkProfile: Boolean, + val targetMimeType: String?, + val systemCost: Long + ) + data class ShareStarted( + val packageName: String?, + val mimeType: String?, + val appProvidedDirect: Int, + val appProvidedApp: Int, + val isWorkprofile: Boolean, + val previewType: Int, + val intent: String?, + val customActionCount: Int, + val modifyShareActionProvided: Boolean + ) + data class ShareTargetSelected( + val targetType: Int, + val packageName: String?, + val positionPicked: Int, + val directTargetAlsoRanked: Int, + val numCallerProvided: Int, + val directTargetHashed: HashedStringCache.HashResult?, + val pinned: Boolean, + val successfullySelected: Boolean, + val selectionCost: Long + ) +} diff --git a/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt b/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt new file mode 100644 index 0000000..dcf8d23 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/logging/FakeFrameworkStatsLogger.kt @@ -0,0 +1,95 @@ +package com.android.intentresolver.logging +/* + * Copyright (C) 2023 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. + */ + +import com.android.internal.util.FrameworkStatsLog + +internal data class ShareSheetStarted( + val frameworkEventId: Int = FrameworkStatsLog.SHARESHEET_STARTED, + val appEventId: Int, + val packageName: String?, + val instanceId: Int, + val mimeType: String?, + val numAppProvidedDirectTargets: Int, + val numAppProvidedAppTargets: Int, + val isWorkProfile: Boolean, + val previewType: Int, + val intentType: Int, + val numCustomActions: Int, + val modifyShareActionProvided: Boolean +) + +internal data class RankingSelected( + val frameworkEventId: Int = FrameworkStatsLog.RANKING_SELECTED, + val appEventId: Int, + val packageName: String?, + val instanceId: Int, + val positionPicked: Int, + val isPinned: Boolean +) + +internal class FakeFrameworkStatsLogger : FrameworkStatsLogger { + var shareSheetStarted: ShareSheetStarted? = null + var rankingSelected: RankingSelected? = null + override fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + mimeType: String?, + numAppProvidedDirectTargets: Int, + numAppProvidedAppTargets: Int, + isWorkProfile: Boolean, + previewType: Int, + intentType: Int, + numCustomActions: Int, + modifyShareActionProvided: Boolean + ) { + shareSheetStarted = + ShareSheetStarted( + frameworkEventId, + appEventId, + packageName, + instanceId, + mimeType, + numAppProvidedDirectTargets, + numAppProvidedAppTargets, + isWorkProfile, + previewType, + intentType, + numCustomActions, + modifyShareActionProvided + ) + } + override fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + positionPicked: Int, + isPinned: Boolean + ) { + rankingSelected = + RankingSelected( + frameworkEventId, + appEventId, + packageName, + instanceId, + positionPicked, + isPinned + ) + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt new file mode 100644 index 0000000..4e27962 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt @@ -0,0 +1,44 @@ +package com.android.intentresolver.v2.platform + +/** + * Creates a SecureSettings instance with predefined values: + * + * val settings = fakeSecureSettings { + * putString("stringValue", "example") + * putInt("intValue", 42) + * } + */ +fun fakeSecureSettings(block: FakeSecureSettings.Builder.() -> Unit): SecureSettings { + return FakeSecureSettings.Builder().apply(block).build() +} + +/** An in memory implementation of [SecureSettings]. */ +class FakeSecureSettings private constructor(private val map: Map<String, String>) : + SecureSettings { + + override fun getString(name: String): String? = map[name] + override fun getInt(name: String): Int? = getString(name)?.toIntOrNull() + override fun getLong(name: String): Long? = getString(name)?.toLongOrNull() + override fun getFloat(name: String): Float? = getString(name)?.toFloatOrNull() + + class Builder { + private val map = mutableMapOf<String, String>() + + fun putString(name: String, value: String) { + map[name] = value + } + fun putInt(name: String, value: Int) { + map[name] = value.toString() + } + fun putLong(name: String, value: Long) { + map[name] = value.toString() + } + fun putFloat(name: String, value: Float) { + map[name] = value.toString() + } + + fun build(): SecureSettings { + return FakeSecureSettings(map.toMap()) + } + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt new file mode 100644 index 0000000..370e5a0 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt @@ -0,0 +1,239 @@ +package com.android.intentresolver.v2.platform + +import android.content.Context +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.pm.UserInfo +import android.content.pm.UserInfo.FLAG_FULL +import android.content.pm.UserInfo.FLAG_INITIALIZED +import android.content.pm.UserInfo.FLAG_PROFILE +import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID +import android.os.IUserManager +import android.os.UserHandle +import android.os.UserManager +import androidx.annotation.NonNull +import com.android.intentresolver.THROWS_EXCEPTION +import com.android.intentresolver.mock +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent +import com.android.intentresolver.v2.platform.FakeUserManager.State +import com.android.intentresolver.whenever +import kotlin.random.Random +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import org.mockito.Mockito.RETURNS_SELF +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.withSettings + +/** + * A stand-in for [UserManager] to support testing of data layer components which depend on it. + * + * This fake targets system applications which need to interact with any or all of the current + * user's associated profiles (as reported by [getEnabledProfiles]). Support for manipulating + * non-profile (full) secondary users (switching active foreground user, adding or removing users) + * is not included. + * + * Upon creation [FakeUserManager] contains a single primary (full) user with a randomized ID. This + * is available from [FakeUserManager.state] using [primaryUserHandle][State.primaryUserHandle] or + * [getPrimaryUser][State.getPrimaryUser]. + * + * To make state changes, use functions available from [FakeUserManager.state]: + * * [createProfile][State.createProfile] + * * [removeProfile][State.removeProfile] + * * [setQuietMode][State.setQuietMode] + * + * Any functionality not explicitly overridden here is guaranteed to throw an exception when + * accessed (access to the real system service is prevented). + */ +class FakeUserManager(val state: State = State()) : + UserManager(/* context = */ mockContext(), /* service = */ mockService()) { + + enum class ProfileType { + WORK, + CLONE, + PRIVATE + } + + override fun getProfileParent(userHandle: UserHandle): UserHandle? { + return state.getUserOrNull(userHandle)?.let { user -> + if (user.isProfile) { + state.getUserOrNull(UserHandle.of(user.profileGroupId))?.userHandle + } else { + null + } + } + } + + override fun getUserInfo(userId: Int): UserInfo? { + return state.getUserOrNull(UserHandle.of(userId)) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getEnabledProfiles(userId: Int): List<UserInfo> { + val user = state.users.single { it.id == userId } + return state.users.filter { other -> + user.id == other.id || user.profileGroupId == other.profileGroupId + } + } + + override fun requestQuietModeEnabled( + enableQuietMode: Boolean, + @NonNull userHandle: UserHandle + ): Boolean { + state.setQuietMode(userHandle, enableQuietMode) + return true + } + + override fun isQuietModeEnabled(userHandle: UserHandle): Boolean { + return state.getUser(userHandle).isQuietModeEnabled + } + + override fun toString(): String { + return "FakeUserManager(state=$state)" + } + + class State { + private val eventChannel = Channel<UserEvent>() + private val userInfoMap: MutableMap<UserHandle, UserInfo> = mutableMapOf() + + /** The id of the primary/full/system user, which is automatically created. */ + val primaryUserHandle: UserHandle + + /** + * Retrieves the primary user. The value returned changes, but the values are immutable. + * + * Do not cache this value in tests, between operations. + */ + fun getPrimaryUser(): UserInfo = getUser(primaryUserHandle) + + private var nextUserId: Int = 100 + Random.nextInt(0, 900) + + /** + * A flow of [UserEvent] which emulates those normally generated from system broadcasts. + * + * Events are produced by calls to [createPrimaryUser], [createProfile], [removeProfile]. + */ + val userEvents: Flow<UserEvent> + + val users: List<UserInfo> + get() = userInfoMap.values.toList() + + val userHandles: List<UserHandle> + get() = userInfoMap.keys.toList() + + init { + primaryUserHandle = createPrimaryUser(allocateNextId()) + userEvents = eventChannel.consumeAsFlow() + } + + private fun allocateNextId() = nextUserId++ + + private fun createPrimaryUser(id: Int): UserHandle { + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_FULL, USER_TYPE_FULL_SYSTEM) + userInfoMap[userInfo.userHandle] = userInfo + return userInfo.userHandle + } + + fun getUserOrNull(handle: UserHandle): UserInfo? = userInfoMap[handle] + + fun getUser(handle: UserHandle): UserInfo = + requireNotNull(getUserOrNull(handle)) { + "Expected userInfoMap to contain an entry for $handle" + } + + fun setQuietMode(user: UserHandle, quietMode: Boolean) { + userInfoMap[user]?.also { + it.flags = + if (quietMode) { + it.flags or UserInfo.FLAG_QUIET_MODE + } else { + it.flags and UserInfo.FLAG_QUIET_MODE.inv() + } + val actions = mutableListOf<String>() + if (quietMode) { + actions += ACTION_PROFILE_UNAVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_UNAVAILABLE + } + } else { + actions += ACTION_PROFILE_AVAILABLE + if (it.isManagedProfile) { + actions += ACTION_MANAGED_PROFILE_AVAILABLE + } + } + actions.forEach { action -> + eventChannel.trySend(UserEvent(action, user, quietMode)) + } + } + } + + fun createProfile(type: ProfileType, parent: UserHandle = primaryUserHandle): UserHandle { + val parentUser = getUser(parent) + require(!parentUser.isProfile) { "Parent user cannot be a profile" } + + // Ensure the parent user has a valid profileGroupId + if (parentUser.profileGroupId == NO_PROFILE_GROUP_ID) { + parentUser.profileGroupId = parentUser.id + } + val id = allocateNextId() + val userInfo = + UserInfo(id, "", "", FLAG_INITIALIZED or FLAG_PROFILE, type.toUserType()).apply { + profileGroupId = parentUser.profileGroupId + } + userInfoMap[userInfo.userHandle] = userInfo + eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle)) + return userInfo.userHandle + } + + fun removeProfile(handle: UserHandle): Boolean { + return userInfoMap[handle]?.let { user -> + require(user.isProfile) { "Only profiles can be removed" } + userInfoMap.remove(user.userHandle) + eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle)) + return true + } + ?: false + } + + override fun toString() = buildString { + append("State(nextUserId=$nextUserId, userInfoMap=[") + userInfoMap.entries.forEach { + append("UserHandle[${it.key.identifier}] = ${it.value.debugString},") + } + append("])") + } + } +} + +/** A safe mock of [Context] which throws on any unstubbed method call. */ +private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { + return mock<Context>(withSettings().defaultAnswer(THROWS_EXCEPTION)) { + doAnswer(RETURNS_SELF).whenever(this).applicationContext + doReturn(user).whenever(this).user + doReturn(user.identifier).whenever(this).userId + } +} + +private fun FakeUserManager.ProfileType.toUserType(): String { + return when (this) { + FakeUserManager.ProfileType.WORK -> UserManager.USER_TYPE_PROFILE_MANAGED + FakeUserManager.ProfileType.CLONE -> UserManager.USER_TYPE_PROFILE_CLONE + FakeUserManager.ProfileType.PRIVATE -> UserManager.USER_TYPE_PROFILE_PRIVATE + } +} + +/** A safe mock of [IUserManager] which throws on any unstubbed method call. */ +fun mockService(): IUserManager { + return mock<IUserManager>(withSettings().defaultAnswer(THROWS_EXCEPTION)) +} + +val UserInfo.debugString: String + get() = + "UserInfo(id=$id, profileGroupId=$profileGroupId, name=$name, " + + "type=$userType, flags=${UserInfo.flagsToString(flags)})" diff --git a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt new file mode 100644 index 0000000..1ff0ce8 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt @@ -0,0 +1,22 @@ +package com.android.intentresolver.v2.validation + +import com.google.common.truth.FailureMetadata +import com.google.common.truth.IterableSubject +import com.google.common.truth.Subject +import com.google.common.truth.Truth.assertAbout + +class ValidationResultSubject(metadata: FailureMetadata, private val actual: ValidationResult<*>?) : + Subject(metadata, actual) { + + fun isSuccess() = check("isSuccess()").that(actual?.isSuccess()).isTrue() + fun isFailure() = check("isSuccess()").that(actual?.isSuccess()).isFalse() + + fun value(): Subject = check("value").that(actual?.value) + + fun findings(): IterableSubject = check("findings").that(actual?.findings) + + companion object { + fun assertThat(input: ValidationResult<*>): ValidationResultSubject = + assertAbout(::ValidationResultSubject).that(input) + } +} |