diff options
| author | 2023-09-11 10:53:02 -0400 | |
|---|---|---|
| committer | 2023-10-10 15:51:55 -0400 | |
| commit | 35018884b00755252f5268507065a7ea6cb9404b (patch) | |
| tree | 642993f8396e95e561a58b6b9022a2614ea24122 /java | |
| parent | 73bad17b4fa5a7c07409ed6d46121614b48adb33 (diff) | |
Inject ComponentNames for image editor and nearby share
Adds a SecureSettings fake
Adds test coverage for new modules
Removes another overload from ChooserActivityWrapper
Uses @BindValue in tests to alter the configured editor component
Test: atest --test-mapping packages/modules/IntentResolver
Bug: 300157408
Bug: 302113519
Change-Id: Ie7d5fe12ad0d8e7fd074154641de35fe89d50ce6
Diffstat (limited to 'java')
17 files changed, 1004 insertions, 55 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 6d56146d..c7c0beeb 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -103,7 +103,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio /** * @param context * @param chooserRequest data about the invocation of the current Sharesheet session. - * @param integratedDeviceComponents info about other components that are available on this * device to implement the supported action types. * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. @@ -239,7 +238,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio clipData = extractTextToCopy(targetIntent); } catch (Throwable t) { Log.e(TAG, "Failed to extract data to copy", t); - return null; + return null; } if (clipData == null) { return null; @@ -372,10 +371,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index fbda8be6..36adf06b 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -1,15 +1,22 @@ package com.android.intentresolver.inject +import android.content.Context import com.android.intentresolver.logging.EventLogImpl import dagger.Module import dagger.Provides +import dagger.Reusable import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module class SingletonModule { - @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() + + @Provides + @Reusable + @ApplicationOwned + fun resources(@ApplicationContext context: Context) = context.resources } diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java new file mode 100644 index 00000000..2da194ca --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -0,0 +1,392 @@ +/* + * 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.v2; + +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityOptions; +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import com.android.intentresolver.ChooserRequestParameters; +import com.android.intentresolver.R; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.ActionRow; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** + * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application + * requirements of Sharesheet / {@link ChooserActivity}. + */ +public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { + /** + * Delegate interface to launch activities when the actions are selected. + */ + public interface ActionActivityStarter { + /** + * Request an activity launch for the provided target. Implementations may choose to exit + * the current activity when the target is launched. + */ + void safelyStartActivityAsPersonalProfileUser(TargetInfo info); + + /** + * Request an activity launch for the provided target, optionally employing the specified + * shared element transition. Implementations may choose to exit the current activity when + * the target is launched. + */ + default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo info, View sharedElement, String sharedElementName) { + safelyStartActivityAsPersonalProfileUser(info); + } + } + + private static final String TAG = "ChooserActions"; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + // Boolean extra used to inform the editor that it may want to customize the editing experience + // for the sharesheet editing flow. + private static final String EDIT_SOURCE = "edit_source"; + private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private final Context mContext; + + @Nullable + private final Runnable mCopyButtonRunnable; + private final Runnable mEditButtonRunnable; + private final ImmutableList<ChooserAction> mCustomActions; + private final @Nullable ChooserAction mModifyShareAction; + private final Consumer<Boolean> mExcludeSharedTextAction; + private final Consumer</* @Nullable */ Integer> mFinishCallback; + private final EventLog mLog; + + /** + * @param context + * @param chooserRequest data about the invocation of the current Sharesheet session. + * @param imageEditor an explicit Activity to launch for editing images + * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" + * setting is updated. The argument is whether the shared text is to be excluded. + * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image + * View in the Sharesheet UI, if any, or null. + * @param activityStarter a delegate to launch activities when actions are selected. + * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was + * completed). + */ + public ChooserActionFactory( + Context context, + ChooserRequestParameters chooserRequest, + Optional<ComponentName> imageEditor, + EventLog log, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer</* @Nullable */ Integer> finishCallback) { + this( + context, + makeCopyButtonRunnable( + context, + chooserRequest.getTargetIntent(), + chooserRequest.getReferrerPackageName(), + finishCallback, + log), + makeEditButtonRunnable( + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + imageEditor), + firstVisibleImageQuery, + activityStarter, + log), + chooserRequest.getChooserActions(), + chooserRequest.getModifyShareAction(), + onUpdateSharedTextIsExcluded, + log, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + @Nullable Runnable copyButtonRunnable, + Runnable editButtonRunnable, + List<ChooserAction> customActions, + @Nullable ChooserAction modifyShareAction, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + EventLog log, + Consumer</* @Nullable */ Integer> finishCallback) { + mContext = context; + mCopyButtonRunnable = copyButtonRunnable; + mEditButtonRunnable = editButtonRunnable; + mCustomActions = ImmutableList.copyOf(customActions); + mModifyShareAction = modifyShareAction; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mLog = log; + mFinishCallback = finishCallback; + } + + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return mEditButtonRunnable; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return mCopyButtonRunnable; + } + + /** Create custom actions */ + @Override + public List<ActionRow.Action> createCustomActions() { + List<ActionRow.Action> actions = new ArrayList<>(); + for (int i = 0; i < mCustomActions.size(); i++) { + final int position = i; + ActionRow.Action actionRow = createCustomAction( + mContext, + mCustomActions.get(i), + mFinishCallback, + () -> { + mLog.logCustomActionSelected(position); + } + ); + if (actionRow != null) { + actions.add(actionRow); + } + } + return actions; + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return createCustomAction( + mContext, + mModifyShareAction, + mFinishCallback, + () -> { + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + }); + } + + /** + * <p> + * Creates an exclude-text action that can be called when the user changes shared text + * status in the Media + Text preview. + * </p> + * <p> + * <code>true</code> argument value indicates that the text should be excluded. + * </p> + */ + @Override + public Consumer<Boolean> getExcludeSharedTextAction() { + return mExcludeSharedTextAction; + } + + @Nullable + private static Runnable makeCopyButtonRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer<Integer> finishCallback, + EventLog log) { + final ClipData clipData; + try { + clipData = extractTextToCopy(targetIntent); + } catch (Throwable t) { + Log.e(TAG, "Failed to extract data to copy", t); + return null; + } + if (clipData == null) { + return null; + } + return () -> { + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + @Nullable + private static ClipData extractTextToCopy(Intent targetIntent) { + if (targetIntent == null) { + return null; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + } + } else { + // expected to only be visible with ACTION_SEND (when a text is shared) + Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); + } + return clipData; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + Optional<ComponentName> imageEditor) { + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + imageEditor.ifPresent(resolveIntent::setComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = context.getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(R.string.screenshot_edit), + "", + resolveIntent); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeEditButtonRunnable( + TargetInfo editSharingTarget, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + EventLog log) { + return () -> { + // Log share completion via edit. + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); + + View firstImageView = null; + try { + firstImageView = firstVisibleImageQuery.call(); + } catch (Exception e) { /* ignore */ } + // Action bar is user-independent; always start as primary. + if (firstImageView == null) { + activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); + } else { + activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); + } + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, + ChooserAction action, + Consumer<Integer> finishCallback, + Runnable loggingRunnable) { + if (action == null || action.getAction() == null) { + return null; + } + Drawable icon = action.getIcon().loadDrawable(context); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send( + null, + 0, + null, + null, + null, + null, + ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + if (loggingRunnable != null) { + loggingRunnable.run(); + } + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9a8b0e2a..12e708f6 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -26,8 +26,6 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL; -import static com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.annotation.IntDef; @@ -76,9 +74,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.ChooserActionFactory; import com.android.intentresolver.ChooserGridLayoutManager; -import com.android.intentresolver.ChooserIntegratedDeviceComponents; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; import com.android.intentresolver.ChooserRequestParameters; @@ -91,7 +87,6 @@ import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.ResolverViewPager; -import com.android.intentresolver.SecureSettings; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -113,12 +108,15 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.v2.Hilt_ChooserActivity; +import com.android.intentresolver.v2.platform.ImageEditor; +import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.widget.ImagePreviewView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.AndroidEntryPoint; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -129,14 +127,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; import javax.inject.Inject; -import dagger.hilt.android.AndroidEntryPoint; - /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. @@ -195,8 +192,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public FeatureFlags mFeatureFlags; @Inject public EventLog mEventLog; - - private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + @Inject @ImageEditor public Optional<ComponentName> mImageEditor; + @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the * only assignment there, and expect it to be ready by the time we ever use it -- @@ -296,8 +293,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetTriggered(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { @@ -373,11 +368,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); } - @VisibleForTesting - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); - } - @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; @@ -1291,7 +1281,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), getReferrerPackageName(), appPredictor, userHandle, getEventLog(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1301,7 +1291,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } return new ChooserListController( @@ -1323,7 +1313,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserActionFactory( this, mChooserRequest, - mIntegratedDeviceComponents, + mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, diff --git a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt new file mode 100644 index 00000000..efbf053e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt @@ -0,0 +1,35 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import androidx.annotation.StringRes +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { + check(getResourceTypeName(resId) == "string") { "resId must be a string" } + return ComponentName.unflattenFromString(getString(resId)) +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor + +@Module +@InstallIn(SingletonComponent::class) +object ImageEditorModule { + /** + * The name of the preferred Activity to launch for editing images. This is added to Intents to + * edit images using Intent.ACTION_EDIT. + */ + @Provides + @Singleton + @ImageEditor + fun imageEditorComponent(@ApplicationOwned resources: Resources) = + Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) +} diff --git a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt new file mode 100644 index 00000000..25ee9198 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt @@ -0,0 +1,32 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare + +@Module +@InstallIn(SingletonComponent::class) +object NearbyShareModule { + + @Provides + @Singleton + @NearbyShare + fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = + Optional.ofNullable( + ComponentName.unflattenFromString( + settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + ?: resources.getString(R.string.config_defaultNearbySharingComponent), + ) + ) +} diff --git a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt new file mode 100644 index 00000000..531152ba --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt @@ -0,0 +1,30 @@ +package com.android.intentresolver.v2.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +/** + * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. + * + * These methods make Binder calls and may block, so use on the Main thread should be avoided. + */ +class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : + SecureSettings { + + override fun getString(name: String): String? { + return Settings.Secure.getString(resolver, name) + } + + override fun getInt(name: String): Int? { + return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() + } + + override fun getLong(name: String): Long? { + return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() + } + + override fun getFloat(name: String): Float? { + return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() + } +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt new file mode 100644 index 00000000..62ee8ae9 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt @@ -0,0 +1,25 @@ +package com.android.intentresolver.v2.platform + +import android.provider.Settings.SettingNotFoundException + +/** + * A component which provides access to values from [android.provider.Settings.Secure]. + * + * All methods return nullable types instead of throwing [SettingNotFoundException] which yields + * cleaner, more idiomatic Kotlin code: + * + * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO + * + * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value + * missing") + */ +interface SecureSettings { + + fun getString(name: String): String? + + fun getInt(name: String): Int? + + fun getLong(name: String): Long? + + fun getFloat(name: String): Float? +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..18f47023 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt @@ -0,0 +1,14 @@ +package com.android.intentresolver.v2.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 5244bf7b..a17400f8 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -50,7 +50,8 @@ android_test { "kotlinx_coroutines_test", "mockito-target-minus-junit4", "testables", - "truth-prebuilt", + "truth", + "truth-java8-extension", "flag-junit", "platform-test-annotations", ], diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 73977f86..53a505df 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -2751,7 +2751,7 @@ public class UnbundledChooserActivityTest { final ChooserActivity activity = mActivityRule.launchActivity( Intent.createChooser(new Intent("ACTION_FOO"), "foo")); waitForIdle(); - assertThat(activity).isInstanceOf(com.android.intentresolver.ChooserWrapperActivity.class); + assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); } private ResolveInfo createFakeResolveInfo() { diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt new file mode 100644 index 00000000..a1a9bc92 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -0,0 +1,232 @@ +/* + * 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.v2 + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.RECEIVER_EXPORTED +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.logging.EventLog +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito + +@RunWith(AndroidJUnit4::class) +class ChooserActionFactoryTest { + private val context = InstrumentationRegistry.getInstrumentation().context + + private val logger = mock<EventLog>() + private val actionLabel = "Action label" + private val modifyShareLabel = "Modify share" + private val testAction = "com.android.intentresolver.testaction" + private val countdown = CountDownLatch(1) + private val testReceiver: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Just doing at most a single countdown per test. + countdown.countDown() + } + } + private val resultConsumer = + object : Consumer<Int> { + var latestReturn = Integer.MIN_VALUE + + override fun accept(resultCode: Int) { + latestReturn = resultCode + } + } + + @Before + fun setup() { + context.registerReceiver(testReceiver, IntentFilter(testAction), RECEIVER_EXPORTED) + } + + @After + fun teardown() { + context.unregisterReceiver(testReceiver) + } + + @Test + fun testCreateCustomActions() { + val factory = createFactory() + + val customActions = factory.createCustomActions() + + assertThat(customActions.size).isEqualTo(1) + assertThat(customActions[0].label).isEqualTo(actionLabel) + + // click it + customActions[0].onClicked.run() + + Mockito.verify(logger).logCustomActionSelected(eq(0)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pending intent has been called + assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) + } + + @Test + fun testNoModifyShareAction() { + val factory = createFactory(includeModifyShare = false) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testModifyShareAction() { + val factory = createFactory(includeModifyShare = true) + + val action = factory.modifyShareAction ?: error("Modify share action should not be null") + action.onClicked.run() + + Mockito.verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pending intent has been called + assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) + } + + @Test + fun nonSendAction_noCopyRunnable() { + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra(Intent.EXTRA_TEXT, "Text to show") + } + + val chooserRequest = + mock<ChooserRequestParameters> { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNull() + } + + @Test + fun sendActionNoText_noCopyRunnable() { + val targetIntent = Intent(Intent.ACTION_SEND) + + val chooserRequest = + mock<ChooserRequestParameters> { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNull() + } + + @Test + fun sendActionWithText_nonNullCopyRunnable() { + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } + + val chooserRequest = + mock<ChooserRequestParameters> { + whenever(this.targetIntent).thenReturn(targetIntent) + whenever(chooserActions).thenReturn(ImmutableList.of()) + } + val testSubject = + ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + {}, + ) + assertThat(testSubject.copyButtonRunnable).isNotNull() + } + + private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + val testPendingIntent = + PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) + val targetIntent = Intent() + val action = + ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + actionLabel, + testPendingIntent + ) + .build() + val chooserRequest = mock<ChooserRequestParameters>() + whenever(chooserRequest.targetIntent).thenReturn(targetIntent) + whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) + + if (includeModifyShare) { + val modifyShare = + ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + modifyShareLabel, + testPendingIntent + ) + .build() + whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) + } + + return ChooserActionFactory( + context, + chooserRequest, + Optional.empty(), + logger, + {}, + { null }, + mock(), + resultConsumer + ) + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 41b31d01..65d33485 100644 --- a/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -19,7 +19,6 @@ package com.android.intentresolver.v2; import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; -import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -35,7 +34,6 @@ import android.os.UserHandle; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.ChooserIntegratedDeviceComponents; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.IChooserWrapper; @@ -128,16 +126,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return new ChooserIntegratedDeviceComponents( - /* editSharingComponent=*/ null, - // An arbitrary pre-installed activity that handles this type of intent: - /* nearbySharingComponent=*/ new ComponentName( - "com.google.android.apps.messaging", - ".ui.conversationlist.ShareIntentActivity")); - } - - @Override public UsageStatsManager getUsageStatsManager() { if (mUsm == null) { mUsm = getSystemService(UsageStatsManager.class); diff --git a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index 1e74c7a5..4a8a5568 100644 --- a/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -17,6 +17,7 @@ package com.android.intentresolver.v2; import static android.app.Activity.RESULT_OK; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -29,16 +30,20 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; +import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_DEFAULT; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; -import static com.android.intentresolver.v2.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; + import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; + import static junit.framework.Assert.assertNull; + import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -126,9 +131,16 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.platform.ImageEditor; +import com.android.intentresolver.v2.platform.ImageEditorModule; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -148,6 +160,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -157,17 +170,14 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - /** * Instrumentation tests for ChooserActivity. * <p> * Legacy test suite migrated from framework CoreTests. - * <p> */ @RunWith(Parameterized.class) @HiltAndroidTest +@UninstallModules(ImageEditorModule.class) public class UnbundledChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { @@ -228,6 +238,13 @@ public class UnbundledChooserActivityTest { private final Function<PackageManager, PackageManager> mPackageManagerOverride; + /** An arbitrary pre-installed activity that handles this type of intent. */ + @BindValue + @ImageEditor + final Optional<ComponentName> mImageEditor = Optional.ofNullable( + ComponentName.unflattenFromString("com.google.android.apps.messaging/" + + ".ui.conversationlist.ShareIntentActivity")); + public UnbundledChooserActivityTest( Function<PackageManager, PackageManager> packageManagerOverride) { mPackageManagerOverride = packageManagerOverride; @@ -897,10 +914,9 @@ public class UnbundledChooserActivityTest { // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. } - - @Test @Ignore public void testEditImageLogs() { + Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = @@ -2195,17 +2211,17 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) .check(matches(isDisplayed())); onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); waitForIdle(); - onView(withId(R.id.chooser_headline_row_container)) + onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) .check(matches(isCompletelyDisplayed())); - onView(withId(R.id.headline)) + onView(withId(com.android.intentresolver.R.id.headline)) .check(matches(isDisplayed())); - onView(withId(R.id.scrollable_image_preview)) + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) .check(matches(not(isDisplayed()))); } diff --git a/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt new file mode 100644 index 00000000..4e279623 --- /dev/null +++ b/java/tests/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/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt new file mode 100644 index 00000000..04c7093d --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt @@ -0,0 +1,61 @@ +package com.android.intentresolver.v2.platform + +import com.google.common.truth.Truth.assertThat + +class FakeSecureSettingsTest { + + private val secureSettings = fakeSecureSettings { + putInt(intKey, intVal) + putString(stringKey, stringVal) + putFloat(floatKey, floatVal) + putLong(longKey, longVal) + } + + fun testExpectedValues_returned() { + assertThat(secureSettings.getInt(intKey)).isEqualTo(intVal) + assertThat(secureSettings.getString(stringKey)).isEqualTo(stringVal) + assertThat(secureSettings.getFloat(floatKey)).isEqualTo(floatVal) + assertThat(secureSettings.getLong(longKey)).isEqualTo(longVal) + } + + fun testUndefinedValues_returnNull() { + assertThat(secureSettings.getInt("unknown")).isNull() + assertThat(secureSettings.getString("unknown")).isNull() + assertThat(secureSettings.getFloat("unknown")).isNull() + assertThat(secureSettings.getLong("unknown")).isNull() + } + + /** + * FakeSecureSettings models the real secure settings by storing values in String form. The + * value is returned if/when it can be parsed from the string value, otherwise null. + */ + fun testMismatchedTypes() { + assertThat(secureSettings.getString(intKey)).isEqualTo(intVal.toString()) + assertThat(secureSettings.getString(floatKey)).isEqualTo(floatVal.toString()) + assertThat(secureSettings.getString(longKey)).isEqualTo(longVal.toString()) + + assertThat(secureSettings.getInt(stringKey)).isNull() + assertThat(secureSettings.getLong(stringKey)).isNull() + assertThat(secureSettings.getFloat(stringKey)).isNull() + + assertThat(secureSettings.getInt(longKey)).isNull() + assertThat(secureSettings.getFloat(longKey)).isNull() // TODO: verify Long.MAX > Float.MAX ? + + assertThat(secureSettings.getLong(floatKey)).isNull() // TODO: or is Float.MAX > Long.MAX? + assertThat(secureSettings.getInt(floatKey)).isNull() + } + + companion object Data { + const val intKey = "int" + const val intVal = Int.MAX_VALUE + + const val stringKey = "string" + const val stringVal = "String" + + const val floatKey = "float" + const val floatVal = Float.MAX_VALUE + + const val longKey = "long" + const val longVal = Long.MAX_VALUE + } +} diff --git a/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt b/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt new file mode 100644 index 00000000..fd5c8b3f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt @@ -0,0 +1,83 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.Context +import android.content.res.Configuration +import android.provider.Settings +import android.testing.TestableResources + +import androidx.test.platform.app.InstrumentationRegistry + +import com.android.intentresolver.R + +import com.google.common.truth.Truth8.assertThat + +import org.junit.Before +import org.junit.Test + +class NearbyShareModuleTest { + + lateinit var context: Context + + /** Create Resources with overridden values. */ + private fun Context.fakeResources( + config: Configuration? = null, + block: TestableResources.() -> Unit + ) = + TestableResources(resources) + .apply { config?.let { overrideConfiguration(it) } } + .apply(block) + .resources + + @Before + fun setup() { + val instr = InstrumentationRegistry.getInstrumentation() + context = instr.context + } + + @Test + fun valueIsAbsent_whenUnset() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { addOverride(R.string.config_defaultNearbySharingComponent, "") } + + val componentName = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + assertThat(componentName).isEmpty() + } + + @Test + fun defaultValue_readFromResources() { + val secureSettings = fakeSecureSettings {} + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.ComponentName" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent).hasValue( + ComponentName.unflattenFromString("com.example/.ComponentName")) + } + + @Test + fun secureSettings_overridesDefault() { + val secureSettings = fakeSecureSettings { + putString(Settings.Secure.NEARBY_SHARING_COMPONENT, "com.example/.BComponent") + } + val resources = + context.fakeResources { + addOverride( + R.string.config_defaultNearbySharingComponent, + "com.example/.AComponent" + ) + } + + val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) + + assertThat(nearbyShareComponent).hasValue( + ComponentName.unflattenFromString("com.example/.BComponent")) + } +} |