Merge changes I25114692,I84f51fc5 into androidx-main
* changes:
Bump Compose to 1.0.0-beta03
Revert "Use prebuilts for compose dependencies"
diff --git a/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/PreviewViewFragment.java b/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/PreviewViewFragment.java
index 9b1a8e2..6933449 100644
--- a/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/PreviewViewFragment.java
+++ b/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/PreviewViewFragment.java
@@ -93,6 +93,10 @@
@SuppressWarnings("WeakerAccess")
Preview mPreview;
+ // Synthetic access
+ @SuppressWarnings("WeakerAccess")
+ ProcessCameraProvider mCameraProvider;
+
public PreviewViewFragment() {
super(R.layout.fragment_preview_view);
}
@@ -108,12 +112,19 @@
super.onViewCreated(view, savedInstanceState);
mPreviewView = view.findViewById(R.id.preview_view);
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
-
+ mPreviewView.addOnLayoutChangeListener(
+ (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom)
+ -> {
+ if (mCameraProvider != null) {
+ bindPreview(mCameraProvider);
+ }
+ });
mBlurBitmap = new BlurBitmap(requireContext());
Futures.addCallback(mCameraProviderFuture, new FutureCallback<ProcessCameraProvider>() {
@Override
public void onSuccess(@Nullable ProcessCameraProvider cameraProvider) {
Preconditions.checkNotNull(cameraProvider);
+ mCameraProvider = cameraProvider;
mPreview = new Preview.Builder()
.setTargetRotation(view.getDisplay().getRotation())
.setTargetName("Preview")
diff --git a/car/app/app/src/main/java/androidx/car/app/navigation/NavigationManager.java b/car/app/app/src/main/java/androidx/car/app/navigation/NavigationManager.java
index c181f0d..7d9d55c 100644
--- a/car/app/app/src/main/java/androidx/car/app/navigation/NavigationManager.java
+++ b/car/app/app/src/main/java/androidx/car/app/navigation/NavigationManager.java
@@ -286,13 +286,16 @@
}
mIsNavigating = false;
- NavigationManagerCallback callback = mNavigationManagerCallback;
- Executor executor = mNavigationManagerCallbackExecutor;
- if (callback == null || executor == null) {
+ if (mNavigationManagerCallbackExecutor == null) {
return;
}
- executor.execute(callback::onStopNavigation);
+ mNavigationManagerCallbackExecutor.execute(() -> {
+ NavigationManagerCallback callback = mNavigationManagerCallback;
+ if (callback != null) {
+ callback.onStopNavigation();
+ }
+ });
}
/**
diff --git a/car/app/app/src/test/java/androidx/car/app/navigation/NavigationManagerTest.java b/car/app/app/src/test/java/androidx/car/app/navigation/NavigationManagerTest.java
index 8e3b9c9..4916000 100644
--- a/car/app/app/src/test/java/androidx/car/app/navigation/NavigationManagerTest.java
+++ b/car/app/app/src/test/java/androidx/car/app/navigation/NavigationManagerTest.java
@@ -74,12 +74,12 @@
new TravelEstimate.Builder(
Distance.create(/* displayDistance= */ 10, Distance.UNIT_KILOMETERS),
createDateTimeWithZone("2020-04-14T15:57:00", "US/Pacific"))
- .setRemainingTimeSeconds(TimeUnit.HOURS.toSeconds(1)).build();
+ .setRemainingTimeSeconds(TimeUnit.HOURS.toSeconds(1)).build();
private final TravelEstimate mDestinationTravelEstimate =
new TravelEstimate.Builder(
Distance.create(/* displayDistance= */ 100, Distance.UNIT_KILOMETERS),
createDateTimeWithZone("2020-04-14T16:57:00", "US/Pacific"))
- .setRemainingTimeSeconds(TimeUnit.HOURS.toSeconds(1)).build();
+ .setRemainingTimeSeconds(TimeUnit.HOURS.toSeconds(1)).build();
private static final String CURRENT_ROAD = "State St.";
private final Trip mTrip =
new Trip.Builder()
@@ -236,6 +236,40 @@
}
@Test
+ public void onStopNavigation_asynchronousCallback_callsIt() throws RemoteException {
+ InOrder inOrder = inOrder(mMockNavHost, mNavigationListener);
+
+ AsynchronousExecutor executor = new AsynchronousExecutor();
+ mNavigationManager.setNavigationManagerCallback(executor,
+ mNavigationListener);
+ mNavigationManager.navigationStarted();
+ inOrder.verify(mMockNavHost).navigationStarted();
+
+ mNavigationManager.onStopNavigation();
+ executor.run();
+
+ inOrder.verify(mNavigationListener).onStopNavigation();
+ }
+
+ @Test
+ public void onStopNavigation_asynchronousCallbackClearedBeforeExecution_doesNotCallIt()
+ throws RemoteException {
+ InOrder inOrder = inOrder(mMockNavHost, mNavigationListener);
+
+ AsynchronousExecutor executor = new AsynchronousExecutor();
+ mNavigationManager.setNavigationManagerCallback(executor,
+ mNavigationListener);
+ mNavigationManager.navigationStarted();
+ inOrder.verify(mMockNavHost).navigationStarted();
+
+ mNavigationManager.onStopNavigation();
+ mNavigationManager.clearNavigationManagerCallback();
+ executor.run();
+
+ inOrder.verify(mNavigationListener, never()).onStopNavigation();
+ }
+
+ @Test
public void onAutoDriveEnabled_callsListener() {
mNavigationManager.setNavigationManagerCallback(new SynchronousExecutor(),
mNavigationListener);
@@ -260,4 +294,19 @@
}
}
+ static class AsynchronousExecutor implements Executor {
+ private Runnable mToRun;
+
+ @Override
+ public void execute(Runnable r) {
+ mToRun = r;
+ }
+
+ void run() {
+ if (mToRun != null) {
+ mToRun.run();
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.kt
index fd4438b..1cac994 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.kt
@@ -16,10 +16,10 @@
package androidx.compose.foundation.text
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -229,13 +229,13 @@
* Taken with the opposite sign defines the x or y position of the text field in the
* horizontal or vertical scroller container correspondingly.
*/
- var offset by mutableStateOf(initial, structuralEqualityPolicy())
+ var offset by mutableStateOf(initial)
/**
* Maximum length by which the text field can be scrolled. Defined as a difference in
* size between the scroller container and the text field.
*/
- var maximum by mutableStateOf(Float.POSITIVE_INFINITY, structuralEqualityPolicy())
+ var maximum by mutableStateOf(0f)
private set
/**
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
index 3e8396f..95d136a 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
@@ -25,6 +25,7 @@
import androidx.compose.runtime.snapshots.StateObject
import androidx.compose.runtime.snapshots.StateRecord
import androidx.compose.runtime.snapshots.newWritableRecord
+import androidx.compose.runtime.snapshots.overwritable
import androidx.compose.runtime.snapshots.readable
import androidx.compose.runtime.snapshots.sync
import androidx.compose.runtime.snapshots.withCurrent
@@ -140,7 +141,7 @@
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
- next.writable(this) { this.value = value }
+ next.overwritable(this, it) { this.value = value }
}
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
index 6414755..dec01ae 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
@@ -1578,6 +1578,27 @@
return newData
}
+internal fun <T : StateRecord> T.overwritableRecord(
+ state: StateObject,
+ snapshot: Snapshot,
+ candidate: T
+): T {
+ if (snapshot.readOnly) {
+ // If the snapshot is read-only, use the snapshot recordModified to report it.
+ snapshot.recordModified(state)
+ }
+ val id = snapshot.id
+
+ if (candidate.snapshotId == id) return candidate
+
+ val newData = newOverwritableRecord(state, snapshot)
+ newData.snapshotId = id
+
+ snapshot.recordModified(state)
+
+ return newData
+}
+
internal fun <T : StateRecord> T.newWritableRecord(state: StateObject, snapshot: Snapshot): T {
// Calling used() on a state object might return the same record for each thread calling
// used() therefore selecting the record to reuse should be guarded.
@@ -1591,16 +1612,32 @@
// cache the result of readable() as the mutating thread calls to writable() can change the
// result of readable().
@Suppress("UNCHECKED_CAST")
- val newData = (used(state, snapshot.id, openSnapshots) as T?)?.apply {
+ val newData = newOverwritableRecord(state, snapshot)
+ newData.assign(this)
+ newData.snapshotId = snapshot.id
+ return newData
+}
+
+internal fun <T : StateRecord> T.newOverwritableRecord(state: StateObject, snapshot: Snapshot): T {
+ // Calling used() on a state object might return the same record for each thread calling
+ // used() therefore selecting the record to reuse should be guarded.
+
+ // Note: setting the snapshotId to Int.MAX_VALUE will make it invalid for all snapshots.
+ // This means the lock can be released as used() will no longer select it. Using id could
+ // also be used but it puts the object into a state where the reused value appears to be
+ // the current valid value for the snapshot. This is not an issue if the snapshot is only
+ // being read from a single thread but using Int.MAX_VALUE allows multiple readers,
+ // single writer, of a snapshot. Note that threads reading a mutating snapshot should not
+ // cache the result of readable() as the mutating thread calls to writable() can change the
+ // result of readable().
+ @Suppress("UNCHECKED_CAST")
+ return (used(state, snapshot.id, openSnapshots) as T?)?.apply {
snapshotId = Int.MAX_VALUE
} ?: create().apply {
snapshotId = Int.MAX_VALUE
this.next = state.firstStateRecord
state.prependStateRecord(this as T)
} as T
- newData.assign(this)
- newData.snapshotId = snapshot.id
- return newData
}
@PublishedApi
@@ -1649,6 +1686,34 @@
}
/**
+ * Call [block] with a writable state record for the given record. It is assumed that this is
+ * called for the first state record in a state object. A record is writable if it was created in
+ * the current mutable snapshot. This should only be used when the record will be overwritten in
+ * its entirety (such as having only one field and that field is written to).
+ *
+ * WARNING: If the caller doesn't overwrite all the fields in the state record the object will be
+ * inconsistent and the fields not written are almost guaranteed to be incorrect. If it is
+ * possible that [block] will not write to all the fields use [writable] instead.
+ *
+ * @param state The object that has this record in its record list.
+ * @param candidate The current for the snapshot record returned by [withCurrent]
+ * @param block The block that will mutate all the field of the record.
+ */
+internal inline fun <T : StateRecord, R> T.overwritable(
+ state: StateObject,
+ candidate: T,
+ block: T.() -> R
+): R {
+ var snapshot: Snapshot = snapshotInitializer
+ return sync {
+ snapshot = Snapshot.current
+ this.overwritableRecord(state, snapshot, candidate).block()
+ }.also {
+ notifyWrite(snapshot, state)
+ }
+}
+
+/**
* Produce a set of optimistic merges of the state records, this is performed outside the
* a synchronization block to reduce the amount of time taken in the synchronization block
* reducing the thread contention of merging state values.
diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
index 3611a1e..f89d206 100644
--- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
+++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
@@ -21,27 +21,18 @@
import androidx.compose.runtime.Stable
/**
- * Immutable constraints used for measuring layouts, usually as part of parent
- * [layouts][androidx.compose.ui.layout.Layout] or
- * [layout modifiers][androidx.compose.ui.layout.LayoutModifier].
- * Children layouts can be measured using the `measure` method on the corresponding
- * [Measurables][androidx.compose.ui.layout.Measurable]. This method takes the [Constraints],
- * in pixels, which the child should respect. A measured child is responsible to choose
- * and return a size which satisfies the set of [Constraints] received from its parent:
- * - minWidth <= chosenWidth <= maxWidth
- * - minHeight <= chosenHeight <= maxHeight
- * The parent can then access the size chosen by the child on the resulting
- * [Placeable][androidx.compose.ui.layout.Placeable]. Based on the children sizes, the parent
- * is responsible for defining a valid positioning of the children. This means that children need
- * to be measured with appropriate [Constraints], such that whatever valid sizes children choose,
- * they can be laid out correctly according to the parent's layout algorithm. Note that
- * different children can be measured with different [Constraints].
- * A child is allowed to choose a size that does not satisfy its constraints. However, when this
- * happens, the parent will not read from the placeable the real size of the child, but rather
- * one that was coerced in the child's constraints; therefore, a parent can assume that its
- * children will always respect the constraints in their layout algorithm. When this does not
- * happen in reality, the position assigned to the child will be automatically offset to be centered
- * on the space assigned by the parent under the assumption that constraints were respected.
+ * Immutable constraints for measuring layouts, used by [layouts][androidx.compose.ui.layout.Layout]
+ * or [layout modifiers][androidx.compose.ui.layout.LayoutModifier] to measure their layout
+ * children. The parent chooses the [Constraints] defining a range, in pixels, within which
+ * the measured layout should choose a size:
+ *
+ * - `minWidth` <= `chosenWidth` <= `maxWidth`
+ * - `minHeight` <= `chosenHeight` <= `maxHeight`
+ *
+ * For more details about how layout measurement works, see
+ * [androidx.compose.ui.layout.MeasurePolicy] or
+ * [androidx.compose.ui.layout.LayoutModifier.measure].
+ *
* A set of [Constraints] can have infinite maxWidth and/or maxHeight. This is a trick often
* used by parents to ask their children for their preferred size: unbounded constraints force
* children whose default behavior is to fill the available space (always size to
@@ -129,7 +120,7 @@
}
/**
- * `false` when [maxHeight] is [Infinity] and `true` if [maxWidth] is a non-[Infinity] value.
+ * `false` when [maxHeight] is [Infinity] and `true` if [maxHeight] is a non-[Infinity] value.
* @see hasBoundedWidth
*/
val hasBoundedHeight: Boolean
@@ -196,7 +187,7 @@
/**
* A value that [maxWidth] or [maxHeight] will be set to when the constraint should
* be considered infinite. [hasBoundedHeight] or [hasBoundedWidth] will be
- * `true` when [maxHeight] or [maxWidth] is [Infinity], respectively.
+ * `false` when [maxWidth] or [maxHeight] is [Infinity], respectively.
*/
const val Infinity = Int.MAX_VALUE
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/AndroidViewCompatTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/AndroidViewCompatTest.kt
index 8a3533b..3aa73c6 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/AndroidViewCompatTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/AndroidViewCompatTest.kt
@@ -30,6 +30,7 @@
import android.widget.LinearLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Applier
@@ -610,6 +611,34 @@
}
}
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ fun testMove_withoutRedraw() {
+ var offset by mutableStateOf(0)
+ rule.setContent {
+ Box(Modifier.testTag("box").fillMaxSize()) {
+ val offsetDp = with(rule.density) { offset.toDp() }
+ Box(Modifier.offset(offsetDp, offsetDp)) {
+ AndroidView(::ColoredSquareView, Modifier.graphicsLayer())
+ }
+ }
+ }
+ val offsetColorProvider: (IntOffset) -> Color? = {
+ if (it.x >= offset && it.x < offset + 100 && it.y >= offset && it.y < offset + 100) {
+ Color.Blue
+ } else {
+ null
+ }
+ }
+ rule.onNodeWithTag("box").captureToImage()
+ .assertPixels(expectedColorProvider = offsetColorProvider)
+ rule.runOnUiThread {
+ offset = 100
+ }
+ rule.onNodeWithTag("box").captureToImage()
+ .assertPixels(expectedColorProvider = offsetColorProvider)
+ }
+
class ColoredSquareView(context: Context) : View(context) {
var size: Int = 100
set(value) {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewsHandler.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewsHandler.android.kt
index 7f9c907..30b40a3 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewsHandler.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewsHandler.android.kt
@@ -63,13 +63,7 @@
override fun invalidateChildInParent(location: IntArray?, dirty: Rect?) = null
fun drawView(view: AndroidViewHolder, canvas: Canvas) {
- // The canvas is already translated by the Compose logic. But the position of the
- // AndroidViewHolder is also set on it inside the AndroidViewsHandler, for correct
- // `getLocationInWindow` results for the Composed View. Therefore, we need to
- // compensate here to avoid double translating.
- canvas.translate(-view.x, -view.y)
- drawChild(canvas, view, drawingTime)
- canvas.translate(view.x, view.y)
+ view.draw(canvas)
}
// Touch events forwarding will be handled by component nodes.
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutModifier.kt
index a57fce3..3888e20 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutModifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutModifier.kt
@@ -45,6 +45,10 @@
* [Placeable], which defines how the wrapped content should be positioned inside
* the [LayoutModifier]. A convenient way to create the [MeasureResult]
* is to use the [MeasureScope.layout] factory function.
+ *
+ * A [LayoutModifier] uses the same measurement and layout concepts and principles as a
+ * [Layout], the only difference is that they apply to exactly one child. For a more detailed
+ * explanation of measurement and layout, see [MeasurePolicy].
*/
fun MeasureScope.measure(
measurable: Measurable,
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/MeasurePolicy.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/MeasurePolicy.kt
index 936d433..8753c20 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/MeasurePolicy.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/MeasurePolicy.kt
@@ -55,11 +55,31 @@
/**
* The function that defines the measurement and layout. Each [Measurable] in the [measurables]
* list corresponds to a layout child of the layout, and children can be measured using the
- * [Measurable.measure] method. Measuring a child returns a [Placeable], which can then
- * be positioned in the [MeasureResult.placeChildren] of the returned [MeasureResult].
- * Usually [MeasureResult] objects are created using the [MeasureScope.layout] factory, which
- * takes the calculated size of this layout, its alignment lines, and a block defining
- * the positioning of the children layouts.
+ * [Measurable.measure] method. This method takes the [Constraints] which the child should
+ * respect; different children can be measured with different constraints.
+ * Measuring a child returns a [Placeable], which reveals the size chosen by the child as a
+ * result of its own measurement. According to the children sizes, the parent is defining the
+ * positioning of the children, by [placing][Placeable.PlacementScope.place] the [Placeable]s in
+ * the [MeasureResult.placeChildren] of the returned [MeasureResult]. Therefore the parent needs
+ * to measure its children with appropriate [Constraints], such that whatever valid sizes
+ * children choose, they can be laid out correctly according to the parent's layout algorithm.
+ * This is because there is no measurement negotiation between the parent and children:
+ * once a child chooses its size, the parent needs to handle it correctly.
+ *
+ * Note that a child is allowed to choose a size that does not satisfy its constraints. However,
+ * when this happens, the placeable's [width][Placeable.width] and [height][Placeable.height]
+ * will not represent the real size of the child, but rather the size coerced in the
+ * child's constraints. Therefore, it is common for parents to assume in their layout
+ * algorithm that its children will always respect the constraints. When this
+ * does not happen in reality, the position assigned to the child will be
+ * automatically offset to be centered on the space assigned by the parent under
+ * the assumption that constraints were respected. Rarely, when a parent really needs to know
+ * the true size of the child, they can read this from the placeable's
+ * [Placeable.measuredWidth] and [Placeable.measuredHeight].
+ *
+ * [MeasureResult] objects are usually created using the [MeasureScope.layout]
+ * factory, which takes the calculated size of this layout, its alignment lines, and a block
+ * defining the positioning of the children layouts.
*/
fun MeasureScope.measure(
measurables: List<Measurable>,
diff --git a/core/core-google-shortcuts/api/restricted_current.txt b/core/core-google-shortcuts/api/restricted_current.txt
index e6f50d0..3827b08 100644
--- a/core/core-google-shortcuts/api/restricted_current.txt
+++ b/core/core-google-shortcuts/api/restricted_current.txt
@@ -1 +1,14 @@
// Signature format: 4.0
+package @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) androidx.core.google.shortcuts {
+
+ @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public class ShortcutInfoChangeListenerImpl {
+ method public static androidx.core.google.shortcuts.ShortcutInfoChangeListenerImpl getInstance(android.content.Context);
+ method public void onAllShortcutsRemoved();
+ method public void onShortcutAdded(java.util.List<androidx.core.content.pm.ShortcutInfoCompat!>);
+ method public void onShortcutRemoved(java.util.List<java.lang.String!>);
+ method public void onShortcutUpdated(java.util.List<androidx.core.content.pm.ShortcutInfoCompat!>);
+ method public void onShortcutUsageReported(java.util.List<java.lang.String!>);
+ }
+
+}
+
diff --git a/core/core-google-shortcuts/build.gradle b/core/core-google-shortcuts/build.gradle
index 8451eed..a5be6a42 100644
--- a/core/core-google-shortcuts/build.gradle
+++ b/core/core-google-shortcuts/build.gradle
@@ -25,9 +25,25 @@
id("kotlin-android")
}
+android {
+ defaultConfig {
+ minSdkVersion 21
+ }
+}
+
dependencies {
api(KOTLIN_STDLIB)
- // Add dependencies here
+ api("androidx.core:core:1.5.0-beta03")
+
+ implementation("com.google.firebase:firebase-appindexing:19.2.0")
+
+ androidTestImplementation(ANDROIDX_TEST_EXT_JUNIT)
+ androidTestImplementation(ANDROIDX_TEST_CORE)
+ androidTestImplementation(ANDROIDX_TEST_RUNNER)
+ androidTestImplementation(ANDROIDX_TEST_RULES)
+ androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy)
+ androidTestImplementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy)
+ androidTestImplementation(TRUTH)
}
androidx {
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java
new file mode 100644
index 0000000..cc73d99
--- /dev/null
+++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2021 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 androidx.core.google.shortcuts;
+
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_DESCRIPTION_KEY;
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_LABEL_KEY;
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_URL_KEY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.core.content.pm.ShortcutInfoCompat;
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.firebase.appindexing.Action;
+import com.google.firebase.appindexing.FirebaseAppIndex;
+import com.google.firebase.appindexing.FirebaseUserActions;
+import com.google.firebase.appindexing.Indexable;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RunWith(AndroidJUnit4.class)
+public class ShortcutInfoChangeListenerImplTest {
+ private static final String TEST_PACKAGE = "com.test.package";
+
+ private FirebaseAppIndex mFirebaseAppIndex;
+ private FirebaseUserActions mFirebaseUserActions;
+ private Context mContext;
+ private ShortcutInfoChangeListenerImpl mShortcutInfoChangeListener;
+
+ @Before
+ public void setUp() {
+ mFirebaseAppIndex = mock(FirebaseAppIndex.class);
+ mFirebaseUserActions = mock(FirebaseUserActions.class);
+ mContext = mock(Context.class);
+ mShortcutInfoChangeListener = new ShortcutInfoChangeListenerImpl(
+ mContext, mFirebaseAppIndex, mFirebaseUserActions);
+
+ when(mContext.getPackageName()).thenReturn(TEST_PACKAGE);
+ }
+
+ @Test
+ @SmallTest
+ public void onShortcutUpdated_publicIntent_savesToAppIndex() throws Exception {
+ ArgumentCaptor<Indexable> indexableCaptor = ArgumentCaptor.forClass(Indexable.class);
+
+ Intent intent = Intent.parseUri("http://www.google.com", 0);
+ ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(mContext, "publicIntent")
+ .setShortLabel("short label")
+ .setLongLabel("long label")
+ .setIntent(intent)
+ .setIcon(IconCompat.createWithContentUri("content://abc"))
+ .build();
+
+ mShortcutInfoChangeListener.onShortcutUpdated(Collections.singletonList(shortcut));
+
+ verify(mFirebaseAppIndex, only()).update(indexableCaptor.capture());
+ List<Indexable> allValues = indexableCaptor.getAllValues();
+ Indexable expected = new Indexable.Builder()
+ .setId("publicIntent")
+ .setUrl(ShortcutUtils.getIndexableUrl(mContext, "publicIntent"))
+ .put(SHORTCUT_LABEL_KEY, "short label")
+ .put(SHORTCUT_DESCRIPTION_KEY, "long label")
+ .put(SHORTCUT_URL_KEY, ShortcutUtils.getIndexableShortcutUrl(mContext, intent))
+ .setImage("content://abc")
+ .build();
+ assertThat(allValues).containsExactly(expected);
+ }
+
+ @Test
+ @SmallTest
+ public void onShortcutUpdated_privateIntent_savesToAppIndex() throws Exception {
+ ArgumentCaptor<Indexable> indexableCaptor = ArgumentCaptor.forClass(Indexable.class);
+
+ String privateIntentUri = "#Intent;component=androidx.core.google.shortcuts.test/androidx"
+ + ".core.google.shortcuts.TrampolineActivity;end";
+ Intent intent = Intent.parseUri(privateIntentUri, 0);
+
+ ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(mContext, "privateIntent")
+ .setShortLabel("short label")
+ .setIntent(intent)
+ .build();
+
+ mShortcutInfoChangeListener.onShortcutUpdated(Collections.singletonList(shortcut));
+
+ verify(mFirebaseAppIndex, only()).update(indexableCaptor.capture());
+ List<Indexable> allValues = indexableCaptor.getAllValues();
+ Indexable expected = new Indexable.Builder()
+ .setUrl(ShortcutUtils.getIndexableUrl(mContext, "privateIntent"))
+ .setId("privateIntent")
+ .put("shortcutLabel", "short label")
+ .put("shortcutUrl", ShortcutUtils.getIndexableShortcutUrl(mContext, intent))
+ .build();
+ assertThat(allValues).containsExactly(expected);
+ }
+
+ @Test
+ @SmallTest
+ public void onShortcutAdded_savesToAppIndex() throws Exception {
+ ArgumentCaptor<Indexable> indexableCaptor = ArgumentCaptor.forClass(Indexable.class);
+
+ Intent intent = Intent.parseUri("http://www.google.com", 0);
+ ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(mContext, "intent")
+ .setShortLabel("short label")
+ .setLongLabel("long label")
+ .setIntent(intent)
+ .setIcon(IconCompat.createWithContentUri("content://abc"))
+ .build();
+
+ mShortcutInfoChangeListener.onShortcutAdded(Collections.singletonList(shortcut));
+
+ verify(mFirebaseAppIndex, only()).update(indexableCaptor.capture());
+ List<Indexable> allValues = indexableCaptor.getAllValues();
+ Indexable expected = new Indexable.Builder()
+ .setId("intent")
+ .setUrl(ShortcutUtils.getIndexableUrl(mContext, "intent"))
+ .put(SHORTCUT_LABEL_KEY, "short label")
+ .put(SHORTCUT_DESCRIPTION_KEY, "long label")
+ .put(SHORTCUT_URL_KEY, ShortcutUtils.getIndexableShortcutUrl(mContext, intent))
+ .setImage("content://abc")
+ .build();
+ assertThat(allValues).containsExactly(expected);
+ }
+
+ @Test
+ @SmallTest
+ public void onShortcutRemoved_removeFromAppIndex() {
+ ArgumentCaptor<String> urlsCaptor = ArgumentCaptor.forClass(String.class);
+
+ mShortcutInfoChangeListener.onShortcutRemoved(Arrays.asList("id1", "id2"));
+
+ verify(mFirebaseAppIndex, only()).remove(urlsCaptor.capture());
+ List<String> urls = urlsCaptor.getAllValues();
+ assertThat(urls).containsExactly(
+ ShortcutUtils.getIndexableUrl(mContext, "id1"),
+ ShortcutUtils.getIndexableUrl(mContext, "id2"));
+ }
+
+ @Test
+ @SmallTest
+ public void onAllShortcutRemoved_removeFromAppIndex() {
+ mShortcutInfoChangeListener.onAllShortcutsRemoved();
+ verify(mFirebaseAppIndex, only()).removeAll();
+ }
+
+ @Test
+ @SmallTest
+ public void onShortcutUsageReported_savesToUserActions() {
+ ArgumentCaptor<Action> actionCaptor = ArgumentCaptor.forClass(Action.class);
+
+ mShortcutInfoChangeListener.onShortcutUsageReported(Arrays.asList("id1", "id2"));
+
+ verify(mFirebaseUserActions, times(2)).end(actionCaptor.capture());
+ List<Action> actions = actionCaptor.getAllValues();
+ List<String> actionsString =
+ actions.stream().map(Object::toString).collect(Collectors.toList());
+ Action expectedAction1 = new Action.Builder(Action.Builder.VIEW_ACTION)
+ .setObject("", ShortcutUtils.getIndexableUrl(mContext, "id1"))
+ .setMetadata(new Action.Metadata.Builder().setUpload(false))
+ .build();
+ Action expectedAction2 = new Action.Builder(Action.Builder.VIEW_ACTION)
+ .setObject("", ShortcutUtils.getIndexableUrl(mContext, "id2"))
+ .setMetadata(new Action.Metadata.Builder().setUpload(false))
+ .build();
+ // Action has no equals comparator, so instead we compare their string forms.
+ assertThat(actionsString).containsExactly(expectedAction1.toString(),
+ expectedAction2.toString());
+ }
+}
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutUtilsTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutUtilsTest.java
new file mode 100644
index 0000000..84862ac
--- /dev/null
+++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutUtilsTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2021 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 androidx.core.google.shortcuts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShortcutUtilsTest {
+ private static final String TEST_PACKAGE = "com.test.package";
+
+ @Test
+ @SmallTest
+ public void testGetIndexableUrl_returnsTrampolineActivityIntentUriWithIdExtra() {
+ Context context = mock(Context.class);
+ when(context.getPackageName()).thenReturn(TEST_PACKAGE);
+
+ String id = "intentId";
+
+ String url = ShortcutUtils.getIndexableUrl(context, id);
+
+ String expectedUri = String.format("intent:#Intent;component=%s/androidx.core.google"
+ + ".shortcuts.TrampolineActivity;S.id=%s;end", TEST_PACKAGE, id);
+ assertThat(url).isEqualTo(expectedUri);
+ }
+
+ @Test
+ @SmallTest
+ public void testGetIndexableShortcutUrl_returnsShortcutUrl() throws Exception {
+ Context context = mock(Context.class);
+ Intent intent = Intent.parseUri("http://www.google.com", 0);
+
+ String shortcutUrl = ShortcutUtils.getIndexableShortcutUrl(context, intent);
+
+ String expectedShortcutUrl = "http://www.google.com";
+ assertThat(shortcutUrl).isEqualTo(expectedShortcutUrl);
+ }
+}
diff --git a/core/core-google-shortcuts/src/main/AndroidManifest.xml b/core/core-google-shortcuts/src/main/AndroidManifest.xml
index c97471d..877e94a 100644
--- a/core/core-google-shortcuts/src/main/AndroidManifest.xml
+++ b/core/core-google-shortcuts/src/main/AndroidManifest.xml
@@ -16,5 +16,11 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="androidx.core.google.shortcuts">
-
+ <application>
+ <activity android:name=".TrampolineActivity"
+ android:noHistory="true"
+ android:theme="@android:style/Theme.NoDisplay"
+ android:exported="false">
+ </activity>
+ </application>
</manifest>
\ No newline at end of file
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java
new file mode 100644
index 0000000..c9f9e80
--- /dev/null
+++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2021 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 androidx.core.google.shortcuts;
+
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_DESCRIPTION_KEY;
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_LABEL_KEY;
+import static androidx.core.google.shortcuts.ShortcutUtils.SHORTCUT_URL_KEY;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.content.pm.ShortcutInfoCompat;
+import androidx.core.graphics.drawable.IconCompat;
+
+import com.google.firebase.appindexing.Action;
+import com.google.firebase.appindexing.FirebaseAppIndex;
+import com.google.firebase.appindexing.FirebaseUserActions;
+import com.google.firebase.appindexing.Indexable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides a listener on changes to shortcuts in ShortcutInfoCompat.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP)
+// TODO (b/182185987): update class to extend ShortcutInfoChangeListener once that is added to core
+public class ShortcutInfoChangeListenerImpl {
+ private final Context mContext;
+ private final FirebaseAppIndex mFirebaseAppIndex;
+ private final FirebaseUserActions mFirebaseUserActions;
+
+ /**
+ * Create an instance of {@link ShortcutInfoChangeListenerImpl}.
+ *
+ * @param context The application context.
+ * @return {@link ShortcutInfoChangeListenerImpl}.
+ */
+ @NonNull
+ public static ShortcutInfoChangeListenerImpl getInstance(@NonNull Context context) {
+ return new ShortcutInfoChangeListenerImpl(context, FirebaseAppIndex.getInstance(context),
+ FirebaseUserActions.getInstance(context));
+ }
+
+ @VisibleForTesting
+ ShortcutInfoChangeListenerImpl(Context context, FirebaseAppIndex firebaseAppIndex,
+ FirebaseUserActions firebaseUserActions) {
+ mContext = context;
+ mFirebaseAppIndex = firebaseAppIndex;
+ mFirebaseUserActions = firebaseUserActions;
+ }
+
+ /**
+ * Called when shortcut is added by {@link androidx.core.content.pm.ShortcutManagerCompat}.
+ *
+ * @param shortcuts list of shortcuts added
+ */
+ public void onShortcutAdded(@NonNull List<ShortcutInfoCompat> shortcuts) {
+ mFirebaseAppIndex.update(buildIndexables(shortcuts));
+ }
+
+ /**
+ * Called when shortcut is updated by {@link androidx.core.content.pm.ShortcutManagerCompat}.
+ *
+ * @param shortcuts list of shortcuts updated
+ */
+ public void onShortcutUpdated(@NonNull List<ShortcutInfoCompat> shortcuts) {
+ mFirebaseAppIndex.update(buildIndexables(shortcuts));
+ }
+
+ /**
+ * Called when shortcut is removed by {@link androidx.core.content.pm.ShortcutManagerCompat}.
+ *
+ * @param shortcutIds list of shortcut ids removed
+ */
+ public void onShortcutRemoved(@NonNull List<String> shortcutIds) {
+ List<String> urls = new ArrayList<>();
+ for (String shortcutId : shortcutIds) {
+ urls.add(ShortcutUtils.getIndexableUrl(mContext, shortcutId));
+ }
+ mFirebaseAppIndex.remove(urls.toArray(new String[0]));
+ }
+
+ /**
+ * Called when shortcut is used by {@link androidx.core.content.pm.ShortcutManagerCompat}.
+ *
+ * @param shortcutIds list of shortcut ids used
+ */
+ public void onShortcutUsageReported(@NonNull List<String> shortcutIds) {
+ for (String shortcutId : shortcutIds) {
+ // Actions reported here is only on-device due to setUpload(false) in buildAction
+ // method.
+ mFirebaseUserActions.end(buildAction(ShortcutUtils.getIndexableUrl(mContext,
+ shortcutId)));
+ }
+ }
+
+ /**
+ * Called when all shortcuts are removed
+ * by {@link androidx.core.content.pm.ShortcutManagerCompat}.
+ */
+ public void onAllShortcutsRemoved() {
+ mFirebaseAppIndex.removeAll();
+ }
+
+ @NonNull
+ private Action buildAction(@NonNull String url) {
+ // The reported action isn't uploaded to the server.
+ Action.Metadata.Builder metadataBuilder = new Action.Metadata.Builder().setUpload(false);
+ return new Action.Builder(Action.Builder.VIEW_ACTION)
+ // Empty label as placeholder.
+ .setObject("", url)
+ .setMetadata(metadataBuilder)
+ .build();
+ }
+
+ @NonNull
+ private Indexable[] buildIndexables(@NonNull List<ShortcutInfoCompat> shortcuts) {
+ List<Indexable> indexables = new ArrayList<>();
+ for (ShortcutInfoCompat shortcut : shortcuts) {
+ indexables.add(buildIndexable(shortcut));
+ }
+ return indexables.toArray(new Indexable[0]);
+ }
+
+ @NonNull
+ private Indexable buildIndexable(@NonNull ShortcutInfoCompat shortcut) {
+ String url = ShortcutUtils.getIndexableUrl(mContext, shortcut.getId());
+ String shortcutUrl = ShortcutUtils.getIndexableShortcutUrl(mContext, shortcut.getIntent());
+
+ Indexable.Builder builder = new Indexable.Builder()
+ .setId(shortcut.getId())
+ .setUrl(url)
+ .put(SHORTCUT_URL_KEY, shortcutUrl)
+ .put(SHORTCUT_LABEL_KEY, shortcut.getShortLabel().toString());
+
+ if (shortcut.getLongLabel() != null) {
+ builder.put(SHORTCUT_DESCRIPTION_KEY, shortcut.getLongLabel().toString());
+ }
+
+ if (shortcut.getIcon() != null && shortcut.getIcon().getType() == IconCompat.TYPE_URI) {
+ builder.setImage(shortcut.getIcon().getUri().toString());
+ }
+
+ // TODO (b/182186140): add logic for matching names and CapabilityBinding
+
+ // By default, the indexable will be saved only on-device.
+ return builder.build();
+ }
+}
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutUtils.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutUtils.java
new file mode 100644
index 0000000..d7b68ef
--- /dev/null
+++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutUtils.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2021 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 androidx.core.google.shortcuts;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/**
+ * Utility methods and constants used by the google shortcuts library.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+class ShortcutUtils {
+ public static final String SHORTCUT_LABEL_KEY = "shortcutLabel";
+ public static final String SHORTCUT_DESCRIPTION_KEY = "shortcutDescription";
+ public static final String SHORTCUT_URL_KEY = "shortcutUrl";
+ public static final String ID_KEY = "id";
+
+ /**
+ * Generate value for Indexable url field. The url field will not be used for anything other
+ * than referencing the Indexable object. But since it requires that it's openable by the
+ * app, we generate it as an intent that opens the Trampoline Activity.
+ *
+ * @param context the app's context.
+ * @param shortcutId the shortcut id used to generate the url.
+ * @return the indexable url.
+ */
+ public static String getIndexableUrl(@NonNull Context context, @NonNull String shortcutId) {
+ Intent intent = new Intent(context, TrampolineActivity.class);
+ intent.putExtra(ID_KEY, shortcutId);
+
+ return intent.toUri(Intent.URI_INTENT_SCHEME);
+ }
+
+ /**
+ * Generate value for Indexable shortcutUrl field. This field will be used by Google
+ * Assistant to open shortcuts.
+ *
+ * @param context the app's context.
+ * @param shortcutIntent the intent that the shortcut opens.
+ * @return the shortcut url.
+ */
+ public static String getIndexableShortcutUrl(@NonNull Context context,
+ @NonNull Intent shortcutIntent) {
+ // TODO (b/182599835): support private shortcut intents by wrapping it inside an intent
+ // that launches the trampoline activity.
+ return shortcutIntent.toUri(0);
+ }
+
+ private ShortcutUtils() {}
+}
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/TrampolineActivity.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/TrampolineActivity.java
new file mode 100644
index 0000000..6100911
--- /dev/null
+++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/TrampolineActivity.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2021 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 androidx.core.google.shortcuts;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+
+/**
+ * Activity used to receives shortcut intents sent from Google, extracts its shortcut url, and
+ * launches it in the scope of the app.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+class TrampolineActivity extends Activity {
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+}
diff --git a/core/core-google-shortcuts/src/main/androidx/core/package-info.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/package-info.java
similarity index 75%
rename from core/core-google-shortcuts/src/main/androidx/core/package-info.java
rename to core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/package-info.java
index 2b06a14..fe061a9 100644
--- a/core/core-google-shortcuts/src/main/androidx/core/package-info.java
+++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/package-info.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2021 The Android Open Source Project
+ * Copyright 2021 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.
@@ -15,6 +15,11 @@
*/
/**
- * Insert package level documentation here
+ * @hide
*/
+@RestrictTo(LIBRARY_GROUP)
package androidx.core.google.shortcuts;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
+
+import androidx.annotation.RestrictTo;
diff --git a/core/core/api/current.txt b/core/core/api/current.txt
index 79dae26..e4767be 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -1074,6 +1074,7 @@
public static class ShortcutInfoCompat.Builder {
ctor public ShortcutInfoCompat.Builder(android.content.Context, String);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder addCapabilityBinding(String, java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!>?);
method public androidx.core.content.pm.ShortcutInfoCompat build();
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setActivity(android.content.ComponentName);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setAlwaysBadged();
@@ -1092,6 +1093,7 @@
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setPersons(androidx.core.app.Person![]);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setRank(int);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setShortLabel(CharSequence);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder setSliceUri(android.net.Uri);
}
public class ShortcutManagerCompat {
diff --git a/core/core/api/public_plus_experimental_current.txt b/core/core/api/public_plus_experimental_current.txt
index 2db02d0..862c495 100644
--- a/core/core/api/public_plus_experimental_current.txt
+++ b/core/core/api/public_plus_experimental_current.txt
@@ -1074,6 +1074,7 @@
public static class ShortcutInfoCompat.Builder {
ctor public ShortcutInfoCompat.Builder(android.content.Context, String);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder addCapabilityBinding(String, java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!>?);
method public androidx.core.content.pm.ShortcutInfoCompat build();
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setActivity(android.content.ComponentName);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setAlwaysBadged();
@@ -1092,6 +1093,7 @@
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setPersons(androidx.core.app.Person![]);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setRank(int);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setShortLabel(CharSequence);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder setSliceUri(android.net.Uri);
}
public class ShortcutManagerCompat {
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index 3fb7af8..a1037c06 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -1152,6 +1152,15 @@
method public static int getProtectionFlags(android.content.pm.PermissionInfo);
}
+ @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class ShortcutInfoChangeListener {
+ ctor public ShortcutInfoChangeListener();
+ method @AnyThread public void onAllShortcutsRemoved();
+ method @AnyThread public void onShortcutAdded(java.util.List<androidx.core.content.pm.ShortcutInfoCompat!>);
+ method @AnyThread public void onShortcutRemoved(java.util.List<java.lang.String!>);
+ method @AnyThread public void onShortcutUpdated(java.util.List<androidx.core.content.pm.ShortcutInfoCompat!>);
+ method @AnyThread public void onShortcutUsageReported(java.util.List<java.lang.String!>);
+ }
+
public class ShortcutInfoCompat {
method public android.content.ComponentName? getActivity();
method public java.util.Set<java.lang.String!>? getCategories();
@@ -1183,6 +1192,7 @@
ctor public ShortcutInfoCompat.Builder(android.content.Context, String);
ctor @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public ShortcutInfoCompat.Builder(androidx.core.content.pm.ShortcutInfoCompat);
ctor @RequiresApi(25) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public ShortcutInfoCompat.Builder(android.content.Context, android.content.pm.ShortcutInfo);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder addCapabilityBinding(String, java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!>?);
method public androidx.core.content.pm.ShortcutInfoCompat build();
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setActivity(android.content.ComponentName);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setAlwaysBadged();
@@ -1201,6 +1211,7 @@
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setPersons(androidx.core.app.Person![]);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setRank(int);
method public androidx.core.content.pm.ShortcutInfoCompat.Builder setShortLabel(CharSequence);
+ method public androidx.core.content.pm.ShortcutInfoCompat.Builder setSliceUri(android.net.Uri);
}
@RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class ShortcutInfoCompatSaver<T> {
diff --git a/core/core/src/androidTest/java/androidx/core/content/pm/ShortcutInfoCompatTest.java b/core/core/src/androidTest/java/androidx/core/content/pm/ShortcutInfoCompatTest.java
index 50637e2..b25919c 100644
--- a/core/core/src/androidTest/java/androidx/core/content/pm/ShortcutInfoCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/content/pm/ShortcutInfoCompatTest.java
@@ -34,8 +34,10 @@
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
+import android.net.Uri;
import android.os.PersistableBundle;
+import androidx.collection.ArrayMap;
import androidx.core.app.Person;
import androidx.core.app.TestActivity;
import androidx.core.content.ContextCompat;
@@ -52,7 +54,11 @@
import org.junit.Test;
import org.junit.runner.RunWith;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
@MediumTest
@@ -158,6 +164,110 @@
}
@Test
+ @SdkSuppress(minSdkVersion = 21)
+ public void testBuilder_setCapabilities() {
+ final String capability1 = "START_EXERCISE";
+ final String capability1Param1 = "exerciseName";
+ final String capability1Param2 = "duration";
+ final String capability1Param1Value1 = "jogging;running";
+ final String capability1Param1Value2 = "sleeping";
+ final String capability2 = "STOP_EXERCISE";
+ final String capability2Param1 = "exerciseName";
+ final String capability2Param2Value1 = "sleeping";
+ final String sliceUri = "slice-content://com.myfitnessapp/exercise{?start,end}";
+
+ /*
+ * Setup capability 1
+ * {
+ * "START_EXERCISE": {
+ * "exerciseName": ["jogging;running","sleeping"],
+ * "duration": null
+ * }
+ * }
+ */
+ final Map<String, List<String>> capability1Params = new ArrayMap<>();
+ final List<String> capability1Params1Values = new ArrayList<>();
+ capability1Params1Values.add(capability1Param1Value1);
+ capability1Params1Values.add(capability1Param1Value2);
+ capability1Params.put(capability1Param1, capability1Params1Values);
+ capability1Params.put(capability1Param2, null);
+
+ /*
+ * Setup capability 2
+ * {
+ * "STOP_EXERCISE": {
+ * "exerciseName": ["sleeping"],
+ * }
+ * }
+ */
+ final Map<String, List<String>> capability2Params = new ArrayMap<>();
+ final List<String> capability2Param2Values = new ArrayList<>();
+ capability2Param2Values.add(capability2Param2Value1);
+ capability2Params.put(capability2Param1, capability2Param2Values);
+
+ final ShortcutInfoCompat compat = mBuilder
+ .addCapabilityBinding(capability1, capability1Params)
+ .addCapabilityBinding(capability2, capability2Params)
+ .setSliceUri(Uri.parse(sliceUri))
+ .build();
+
+ /*
+ * Verify the extras contains mapping of capability to their parameter names.
+ * {
+ * "START_EXERCISE": ["exerciseName","duration"],
+ * "STOP_EXERCISE": ["exerciseName"],
+ * }
+ */
+ final Set<String> categories = compat.mCategories;
+ assertNotNull(categories);
+ assertTrue(categories.contains(capability1));
+ assertTrue(categories.contains(capability2));
+ final PersistableBundle extra = compat.getExtras();
+ assertNotNull(extra);
+ assertTrue(extra.containsKey(capability1));
+ assertTrue(extra.containsKey(capability2));
+ final String[] paramNamesForCapability1 = extra.getStringArray(capability1);
+ final String[] paramNamesForCapability2 = extra.getStringArray(capability2);
+ assertNotNull(paramNamesForCapability1);
+ assertNotNull(paramNamesForCapability2);
+ assertEquals(2, paramNamesForCapability1.length);
+ assertEquals(1, paramNamesForCapability2.length);
+ final List<String> parameterListForCapability1 = Arrays.asList(paramNamesForCapability1);
+ assertTrue(parameterListForCapability1.contains(capability1Param1));
+ assertTrue(parameterListForCapability1.contains(capability1Param2));
+ assertEquals(capability2Param1, paramNamesForCapability2[0]);
+
+ /*
+ * Verify the extras contains mapping of capability params to their values.
+ * {
+ * "START_EXERCISE/exerciseName": ["jogging;running","sleeping"],
+ * "START_EXERCISE/duration": [],
+ * "STOP_EXERCISE/exerciseName": ["sleeping"],
+ * }
+ */
+ final String capability1Param1Key = capability1 + "/" + capability1Param1;
+ final String capability1Param2Key = capability1 + "/" + capability1Param2;
+ final String capability2Param1Key = capability2 + "/" + capability2Param1;
+ assertTrue(extra.containsKey(capability1Param1Key));
+ assertTrue(extra.containsKey(capability1Param2Key));
+ assertTrue(extra.containsKey(capability2Param1Key));
+ final String[] actualCapability1Params1 = extra.getStringArray(capability1Param1Key);
+ final String[] actualCapability1Params2 = extra.getStringArray(capability1Param2Key);
+ final String[] actualCapability2Params1 = extra.getStringArray(capability2Param1Key);
+ assertNotNull(actualCapability1Params1);
+ assertEquals(2, actualCapability1Params1.length);
+ assertEquals(capability1Param1Value1, actualCapability1Params1[0]);
+ assertEquals(capability1Param1Value2, actualCapability1Params1[1]);
+ assertNotNull(actualCapability1Params2);
+ assertEquals(0, actualCapability1Params2.length);
+ assertNotNull(actualCapability2Params1);
+ assertEquals(1, actualCapability2Params1.length);
+ assertEquals(capability2Param2Value1, actualCapability2Params1[0]);
+ assertTrue(extra.containsKey("extraSliceUri"));
+ assertEquals(sliceUri, extra.getString("extraSliceUri"));
+ }
+
+ @Test
@SdkSuppress(minSdkVersion = 25)
public void testBuilder_copyConstructor() {
String longLabel = "Test long label";
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java
new file mode 100644
index 0000000..9eb69cc
--- /dev/null
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2021 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 androidx.core.content.pm;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+import java.util.List;
+
+/**
+ * Defines a listener for {@link ShortcutInfoCompat} changes in {@link ShortcutManagerCompat}. This
+ * class is no-op as is and may be overridden to provide the required functionality.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+public abstract class ShortcutInfoChangeListener {
+ @AnyThread
+ public void onShortcutAdded(@NonNull List<ShortcutInfoCompat> shortcuts) {}
+
+ @AnyThread
+ public void onShortcutUpdated(@NonNull List<ShortcutInfoCompat> shortcuts) {}
+
+ @AnyThread
+ public void onShortcutRemoved(@NonNull List<String> shortcutIds) {}
+
+ @AnyThread
+ public void onAllShortcutsRemoved() {}
+
+ @AnyThread
+ public void onShortcutUsageReported(@NonNull List<String> shortcutIds) {}
+
+ /**
+ * Implementation that does nothing and returns directly from asynchronous methods.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY)
+ public static class NoopImpl extends ShortcutInfoChangeListener {}
+}
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
index 7751de0..dfbc80e 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
@@ -17,6 +17,7 @@
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@@ -24,6 +25,7 @@
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.drawable.Drawable;
+import android.net.Uri;
import android.os.Build;
import android.os.PersistableBundle;
import android.os.UserHandle;
@@ -37,11 +39,14 @@
import androidx.core.app.Person;
import androidx.core.content.LocusIdCompat;
import androidx.core.graphics.drawable.IconCompat;
+import androidx.core.net.UriCompat;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
/**
@@ -54,6 +59,8 @@
private static final String EXTRA_LOCUS_ID = "extraLocusId";
private static final String EXTRA_LONG_LIVED = "extraLongLived";
+ private static final String EXTRA_SLICE_URI = "extraSliceUri";
+
Context mContext;
String mId;
String mPackageName;
@@ -490,6 +497,9 @@
private final ShortcutInfoCompat mInfo;
private boolean mIsConversation;
+ private Set<String> mCapabilityBindings;
+ private Map<String, Map<String, List<String>>> mCapabilityBindingParams;
+ private Uri mSliceUri;
public Builder(@NonNull Context context, @NonNull String id) {
mInfo = new ShortcutInfoCompat();
@@ -744,7 +754,7 @@
* <li> Used by the system to associate a published Sharing Shortcut with supported
* mimeTypes. Required for published Sharing Shortcuts with a matching category
* declared in share targets, defined in the app's manifest linked shortcuts xml file.
- * </ul>
+ * </ul>
*
* @see ShortcutInfo#getCategories()
*/
@@ -802,8 +812,53 @@
}
/**
+ * Associates a shortcut with a capability. Used when the shortcut is an instance
+ * of a capability.
+ *
+ * <P>This method can be called multiple times to associate multiple capabilities with
+ * this shortcut.
+ *
+ * @param parameters Optional capability parameters associated with given
+ * capability. This will be a mapping of parameter names to zero or
+ * more parameter values. e.g. {"START_EXERCISE": ["jogging", "dancing"],
+ * "STOP_EXERCISE": []}
+ */
+ @SuppressLint("MissingGetterMatchingBuilder")
+ @NonNull
+ public Builder addCapabilityBinding(@NonNull String capability,
+ @Nullable Map<String, List<String>> parameters) {
+ if (mCapabilityBindings == null) {
+ mCapabilityBindings = new HashSet<>();
+ }
+ mCapabilityBindings.add(capability);
+
+ if (parameters != null) {
+ if (mCapabilityBindingParams == null) {
+ mCapabilityBindingParams = new HashMap<>();
+ }
+ if (mCapabilityBindingParams.get(capability) == null) {
+ mCapabilityBindingParams.put(capability, new HashMap<String, List<String>>());
+ }
+ mCapabilityBindingParams.get(capability).putAll(parameters);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the slice uri for a shortcut. The uri will be used if this shortcuts represents a
+ * slice, instead of an intent.
+ */
+ @SuppressLint("MissingGetterMatchingBuilder")
+ @NonNull
+ public Builder setSliceUri(@NonNull Uri sliceUri) {
+ mSliceUri = sliceUri;
+ return this;
+ }
+
+ /**
* Creates a {@link ShortcutInfoCompat} instance.
*/
+ @SuppressLint("UnsafeNewApiCall")
@NonNull
public ShortcutInfoCompat build() {
// Verify the arguments
@@ -819,6 +874,41 @@
}
mInfo.mIsLongLived = true;
}
+
+ if (mCapabilityBindings != null) {
+ if (mInfo.mCategories == null) {
+ mInfo.mCategories = new HashSet<>();
+ }
+ mInfo.mCategories.addAll(mCapabilityBindings);
+ }
+ if (Build.VERSION.SDK_INT >= 21) {
+ if (mCapabilityBindingParams != null) {
+ if (mInfo.mExtras == null) {
+ mInfo.mExtras = new PersistableBundle();
+ }
+ for (String capability : mCapabilityBindingParams.keySet()) {
+ final Map<String, List<String>> params =
+ mCapabilityBindingParams.get(capability);
+ final Set<String> paramNames = params.keySet();
+ // Persist the mapping of <Capability1> -> [<Param1>, <Param2> ... ]
+ mInfo.mExtras.putStringArray(
+ capability, paramNames.toArray(new String[0]));
+ // Persist the capability param in respect to capability
+ // i.e. <Capability1/Param1> -> [<Value1>, <Value2> ... ]
+ for (String paramName : params.keySet()) {
+ final List<String> value = params.get(paramName);
+ mInfo.mExtras.putStringArray(capability + "/" + paramName,
+ value == null ? new String[0] : value.toArray(new String[0]));
+ }
+ }
+ }
+ if (mSliceUri != null) {
+ if (mInfo.mExtras == null) {
+ mInfo.mExtras = new PersistableBundle();
+ }
+ mInfo.mExtras.putString(EXTRA_SLICE_URI, UriCompat.toSafeString(mSliceUri));
+ }
+ }
return mInfo;
}
}
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
index 1e2a225..1478ad1 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
@@ -124,6 +124,13 @@
*/
private static volatile ShortcutInfoCompatSaver<?> sShortcutInfoCompatSaver = null;
+ /**
+ * Will be instantiated by reflection to load an implementation from another module if possible.
+ * If fails to load an implementation via reflection, will use the default implementation which
+ * is no-op.
+ */
+ private static volatile ShortcutInfoChangeListener sShortcutInfoChangeListener = null;
+
private ShortcutManagerCompat() {
/* Hide constructor */
}
@@ -310,6 +317,7 @@
}
getShortcutInfoSaverInstance(context).addShortcuts(shortcutInfoList);
+ getShortcutInfoListenerInstance(context).onShortcutAdded(shortcutInfoList);
return true;
}
@@ -397,6 +405,9 @@
if (Build.VERSION.SDK_INT >= 25) {
context.getSystemService(ShortcutManager.class).reportShortcutUsed(shortcutId);
}
+
+ getShortcutInfoListenerInstance(context)
+ .onShortcutUsageReported(Collections.singletonList(shortcutId));
}
/**
@@ -435,6 +446,9 @@
}
getShortcutInfoSaverInstance(context).removeAllShortcuts();
getShortcutInfoSaverInstance(context).addShortcuts(shortcutInfoList);
+
+ getShortcutInfoListenerInstance(context).onAllShortcutsRemoved();
+ getShortcutInfoListenerInstance(context).onShortcutAdded(shortcutInfoList);
return true;
}
@@ -493,6 +507,7 @@
}
getShortcutInfoSaverInstance(context).addShortcuts(shortcutInfoList);
+ getShortcutInfoListenerInstance(context).onShortcutUpdated(shortcutInfoList);
return true;
}
@@ -556,6 +571,7 @@
}
getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds);
+ getShortcutInfoListenerInstance(context).onShortcutRemoved(shortcutIds);
}
/**
@@ -583,6 +599,7 @@
}
getShortcutInfoSaverInstance(context).addShortcuts(shortcutInfoList);
+ getShortcutInfoListenerInstance(context).onShortcutAdded(shortcutInfoList);
}
/**
@@ -599,6 +616,7 @@
}
getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds);
+ getShortcutInfoListenerInstance(context).onShortcutRemoved(shortcutIds);
}
/**
@@ -614,6 +632,7 @@
}
getShortcutInfoSaverInstance(context).removeAllShortcuts();
+ getShortcutInfoListenerInstance(context).onAllShortcutsRemoved();
}
/**
@@ -636,6 +655,7 @@
context.getSystemService(ShortcutManager.class).removeLongLivedShortcuts(shortcutIds);
getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds);
+ getShortcutInfoListenerInstance(context).onShortcutRemoved(shortcutIds);
}
/**
@@ -709,6 +729,9 @@
return true;
} catch (Exception e) {
// Ignore
+ } finally {
+ getShortcutInfoListenerInstance(context)
+ .onShortcutAdded(Collections.singletonList(shortcut));
}
return false;
}
@@ -766,6 +789,29 @@
return sShortcutInfoCompatSaver;
}
+ private static ShortcutInfoChangeListener getShortcutInfoListenerInstance(Context context) {
+ if (sShortcutInfoChangeListener == null) {
+ if (Build.VERSION.SDK_INT >= 21) {
+ try {
+ ClassLoader loader = ShortcutManagerCompat.class.getClassLoader();
+ Class<?> listener =
+ Class.forName(
+ "androidx.core.google.shortcuts.ShortcutInfoChangeListenerImpl",
+ false, loader);
+ Method getInstanceMethod = listener.getMethod("getInstance", Context.class);
+ sShortcutInfoChangeListener =
+ (ShortcutInfoChangeListener) getInstanceMethod.invoke(null, context);
+ } catch (Exception e) { /* Do nothing */ }
+
+ if (sShortcutInfoChangeListener == null) {
+ // Implementation not available. Instantiate to the default no-op impl.
+ sShortcutInfoChangeListener = new ShortcutInfoChangeListener.NoopImpl();
+ }
+ }
+ }
+ return sShortcutInfoChangeListener;
+ }
+
@RequiresApi(25)
private static class Api25Impl {
static String getShortcutInfoWithLowestRank(@NonNull final List<ShortcutInfo> shortcuts) {