blob: 474b240f970263ed3cb2fa22d46d4fc36727850f [file] [log] [blame]
/*
* 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.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.content.IntentSender;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcel;
import android.os.ResultReceiver;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.android.intentresolver.chooser.TargetInfo;
import dagger.hilt.android.lifecycle.HiltViewModel;
import java.util.List;
import java.util.function.Consumer;
import javax.inject.Inject;
/**
* Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement
* activity" that will be invoked when a target is selected, allowing the calling app to add
* additional extras and other refinements (subject to {@link Intent#filterEquals}), e.g., to
* convert the format of the payload, or lazy-download some data that was deferred in the original
* call).
*/
@HiltViewModel
@UiThread
public final class ChooserRefinementManager extends ViewModel {
private static final String TAG = "ChooserRefinement";
@Nullable // Non-null only during an active refinement session.
private RefinementResultReceiver mRefinementResultReceiver;
private boolean mConfigurationChangeInProgress = false;
/**
* A token for the completion of a refinement process that can be consumed exactly once.
*/
public static class RefinementCompletion {
private TargetInfo mTargetInfo;
private boolean mConsumed;
RefinementCompletion(TargetInfo targetInfo) {
mTargetInfo = targetInfo;
}
/**
* @return The output of the completed refinement process. Null if the process was aborted
* or failed.
*/
public TargetInfo getTargetInfo() {
return mTargetInfo;
}
/**
* Mark this event as consumed if it wasn't already.
*
* @return true if this had not already been consumed.
*/
public boolean consume() {
if (!mConsumed) {
mConsumed = true;
return true;
}
return false;
}
}
private MutableLiveData<RefinementCompletion> mRefinementCompletion = new MutableLiveData<>();
@Inject
public ChooserRefinementManager() {}
public LiveData<RefinementCompletion> getRefinementCompletion() {
return mRefinementCompletion;
}
/**
* Delegate the user's {@code selectedTarget} to the refinement flow, if possible.
* @return true if the selection should wait for a now-started refinement flow, or false if it
* can proceed by the default (non-refinement) logic.
*/
public boolean maybeHandleSelection(TargetInfo selectedTarget,
IntentSender refinementIntentSender, Application application, Handler mainHandler) {
if (refinementIntentSender == null) {
return false;
}
if (selectedTarget.getAllSourceIntents().isEmpty()) {
return false;
}
if (selectedTarget.isSuspended()) {
// We expect all launches to fail for this target, so don't make the user go through the
// refinement flow first. Besides, the default (non-refinement) handling displays a
// warning in this case and recovers the session; we won't be equipped to recover if
// problems only come up after refinement.
return false;
}
destroy(); // Terminate any prior sessions.
mRefinementResultReceiver = new RefinementResultReceiver(
refinedIntent -> {
destroy();
TargetInfo refinedTarget =
selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent);
if (refinedTarget != null) {
mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget));
} else {
Log.e(TAG, "Failed to apply refinement to any matching source intent");
mRefinementCompletion.setValue(new RefinementCompletion(null));
}
},
() -> {
destroy();
mRefinementCompletion.setValue(new RefinementCompletion(null));
},
mainHandler);
Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget);
try {
refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null);
return true;
} catch (IntentSender.SendIntentException e) {
Log.e(TAG, "Refinement IntentSender failed to send", e);
}
return true;
}
/** ChooserActivity has stopped */
public void onActivityStop(boolean configurationChanging) {
mConfigurationChangeInProgress = configurationChanging;
}
/** ChooserActivity has resumed */
public void onActivityResume() {
if (mConfigurationChangeInProgress) {
mConfigurationChangeInProgress = false;
} else {
if (mRefinementResultReceiver != null) {
// This can happen if the refinement activity terminates without ever sending a
// response to our `ResultReceiver`. We're probably not prepared to return the user
// into a valid Chooser session, so we'll treat it as a cancellation instead.
Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting");
destroy();
mRefinementCompletion.setValue(new RefinementCompletion(null));
}
}
}
@Override
protected void onCleared() {
// App lifecycle over, time to clean up.
destroy();
}
/** Clean up any ongoing refinement session. */
private void destroy() {
if (mRefinementResultReceiver != null) {
mRefinementResultReceiver.destroyReceiver();
mRefinementResultReceiver = null;
}
}
private static Intent makeRefinementRequest(
RefinementResultReceiver resultReceiver, TargetInfo originalTarget) {
final Intent fillIn = new Intent();
final List<Intent> sourceIntents = originalTarget.getAllSourceIntents();
fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
final int sourceIntentCount = sourceIntents.size();
if (sourceIntentCount > 1) {
fillIn.putExtra(
Intent.EXTRA_ALTERNATE_INTENTS,
sourceIntents
.subList(1, sourceIntentCount)
.toArray(new Intent[sourceIntentCount - 1]));
}
fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending());
return fillIn;
}
private static class RefinementResultReceiver extends ResultReceiver {
private final Consumer<Intent> mOnSelectionRefined;
private final Runnable mOnRefinementCancelled;
private boolean mDestroyed;
RefinementResultReceiver(
Consumer<Intent> onSelectionRefined,
Runnable onRefinementCancelled,
Handler handler) {
super(handler);
mOnSelectionRefined = onSelectionRefined;
mOnRefinementCancelled = onRefinementCancelled;
}
public void destroyReceiver() {
mDestroyed = true;
}
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
if (mDestroyed) {
Log.e(TAG, "Destroyed RefinementResultReceiver received a result");
return;
}
destroyReceiver(); // This is the single callback we'll accept from this session.
Intent refinedResult = tryToExtractRefinedResult(resultCode, resultData);
if (refinedResult == null) {
mOnRefinementCancelled.run();
} else {
mOnSelectionRefined.accept(refinedResult);
}
}
/**
* Apps can't load this class directly, so we need a regular ResultReceiver copy for
* sending. Obtain this by parceling and unparceling (one weird trick).
*/
ResultReceiver copyForSending() {
Parcel parcel = Parcel.obtain();
writeToParcel(parcel, 0);
parcel.setDataPosition(0);
ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel);
parcel.recycle();
return receiverForSending;
}
/**
* Get the refinement from the result data, if possible, or log diagnostics and return null.
*/
@Nullable
private static Intent tryToExtractRefinedResult(int resultCode, Bundle resultData) {
if (Activity.RESULT_CANCELED == resultCode) {
Log.i(TAG, "Refinement canceled by caller");
} else if (Activity.RESULT_OK != resultCode) {
Log.w(TAG, "Canceling refinement on unrecognized result code " + resultCode);
} else if (resultData == null) {
Log.e(TAG, "RefinementResultReceiver received null resultData; canceling");
} else if (!(resultData.getParcelable(Intent.EXTRA_INTENT) instanceof Intent)) {
Log.e(TAG, "No valid Intent.EXTRA_INTENT in 'OK' refinement result data");
} else {
return resultData.getParcelable(Intent.EXTRA_INTENT, Intent.class);
}
return null;
}
}
}