|
5 | 5 | package org.chromium.chrome.browser.search_engines.choice_screen; |
6 | 6 |
|
7 | 7 | import androidx.annotation.IntDef; |
| 8 | +import androidx.annotation.MainThread; |
8 | 9 | import androidx.annotation.NonNull; |
9 | 10 | import androidx.annotation.Nullable; |
10 | 11 |
|
11 | 12 | import org.chromium.base.Callback; |
12 | 13 | import org.chromium.base.Log; |
| 14 | +import org.chromium.base.ThreadUtils; |
13 | 15 | import org.chromium.base.supplier.ObservableSupplier; |
14 | 16 | import org.chromium.components.search_engines.SearchEngineChoiceService; |
| 17 | +import org.chromium.components.search_engines.SearchEnginesFeatureUtils; |
15 | 18 |
|
16 | 19 | import java.lang.annotation.Retention; |
17 | 20 | import java.lang.annotation.RetentionPolicy; |
18 | 21 |
|
19 | 22 | class ChoiceDialogMediator { |
20 | | - @IntDef({DialogType.UNKNOWN, DialogType.CHOICE_LAUNCH, DialogType.CHOICE_CONFIRM}) |
| 23 | + @IntDef({ |
| 24 | + DialogType.UNKNOWN, |
| 25 | + DialogType.LOADING, |
| 26 | + DialogType.CHOICE_LAUNCH, |
| 27 | + DialogType.CHOICE_CONFIRM |
| 28 | + }) |
21 | 29 | @Retention(RetentionPolicy.SOURCE) |
22 | 30 | @interface DialogType { |
23 | 31 | int UNKNOWN = 0; |
24 | | - int CHOICE_LAUNCH = 1; |
25 | | - int CHOICE_CONFIRM = 2; |
| 32 | + int LOADING = 1; |
| 33 | + int CHOICE_LAUNCH = 2; |
| 34 | + int CHOICE_CONFIRM = 3; |
26 | 35 | } |
27 | 36 |
|
28 | 37 | /** See {@link #startObserving}. */ |
@@ -50,7 +59,25 @@ interface Delegate { |
50 | 59 | private final Callback<Boolean> mIsDeviceChoiceRequiredObserver; |
51 | 60 |
|
52 | 61 | private @DialogType int mDialogType = DialogType.UNKNOWN; |
53 | | - private boolean mIsDialogShown; |
| 62 | + |
| 63 | + /** |
| 64 | + * Either the time at which the blocking dialog was shown, {@code null} indicating that the |
| 65 | + * dialog was not shown yet, or {@link Long#MIN_VALUE} indicating that the dialog has been |
| 66 | + * dismissed. |
| 67 | + */ |
| 68 | + private @Nullable Long mDialogAddedTimeMillis; |
| 69 | + |
| 70 | + /** |
| 71 | + * Either the time at which observing the service started, or {@code null} if it didn't happen |
| 72 | + * yet. |
| 73 | + */ |
| 74 | + private @Nullable Long mObservationStartedTimeMillis; |
| 75 | + |
| 76 | + /** |
| 77 | + * Either the time at which the first service event was received, or {@code null} if it didn't |
| 78 | + * happen yet. |
| 79 | + */ |
| 80 | + private @Nullable Long mFirstServiceEventTimeMillis; |
54 | 81 |
|
55 | 82 | private @Nullable Delegate mDelegate; |
56 | 83 |
|
@@ -81,6 +108,20 @@ void startObserving(@NonNull Delegate delegate) { |
81 | 108 | assert mDelegate == null; |
82 | 109 | mDelegate = delegate; |
83 | 110 |
|
| 111 | + mObservationStartedTimeMillis = System.currentTimeMillis(); |
| 112 | + mDialogType = DialogType.LOADING; |
| 113 | + |
| 114 | + if (!mIsDeviceChoiceRequiredSupplier.hasValue()) { |
| 115 | + // An initial response from the supplier is still pending, so it won't call the observer |
| 116 | + // on registration by itself. It's unclear how long it would take. We proactively |
| 117 | + // trigger the blocking dialog, but if it takes too long we will unblock the user. |
| 118 | + // We do it asynchronously to match how it is done via the supplier when it has a value. |
| 119 | + ThreadUtils.postOnUiThread( |
| 120 | + () -> { |
| 121 | + mDelegate.updateDialogType(DialogType.LOADING); |
| 122 | + mDelegate.showDialog(); |
| 123 | + }); |
| 124 | + } |
84 | 125 | mIsDeviceChoiceRequiredSupplier.addObserver(mIsDeviceChoiceRequiredObserver); |
85 | 126 | } |
86 | 127 |
|
@@ -108,67 +149,129 @@ void onActionButtonClick(@DialogType int dialogType) { |
108 | 149 | switch (dialogType) { |
109 | 150 | case DialogType.CHOICE_LAUNCH -> mSearchEngineChoiceService.launchDeviceChoiceScreens(); |
110 | 151 | case DialogType.CHOICE_CONFIRM -> mDelegate.dismissDialog(); |
111 | | - case DialogType.UNKNOWN -> throw new IllegalStateException(); |
| 152 | + case DialogType.LOADING, DialogType.UNKNOWN -> throw new IllegalStateException(); |
112 | 153 | } |
113 | 154 | } |
114 | 155 |
|
115 | 156 | /** Method to call when the dialog is actually shown. */ |
116 | 157 | void onDialogAdded() { |
117 | | - mIsDialogShown = true; |
| 158 | + assert mDialogAddedTimeMillis == null |
| 159 | + : "The dialog is not expected to have already been shown"; |
| 160 | + assert mDialogType != DialogType.UNKNOWN; |
| 161 | + assert mObservationStartedTimeMillis != null; |
| 162 | + mDialogAddedTimeMillis = System.currentTimeMillis(); |
118 | 163 | mSearchEngineChoiceService.notifyDeviceChoiceBlockShown(); |
| 164 | + |
| 165 | + // TODO(b/355201070): Replace this after e2e testing with UMA recording. |
| 166 | + Log.i( |
| 167 | + TAG, |
| 168 | + "onDialogAdded(), time since observation start: %s millis", |
| 169 | + mDialogAddedTimeMillis - mObservationStartedTimeMillis); |
| 170 | + scheduleDismissOnDeviceChoiceRequiredUpdateTimeout(); |
119 | 171 | } |
120 | 172 |
|
121 | 173 | void onDialogDismissed() { |
122 | | - mIsDialogShown = false; |
123 | 174 | destroy(); |
124 | 175 | } |
125 | 176 |
|
| 177 | + @MainThread |
126 | 178 | private void onIsDeviceChoiceRequiredChanged(@Nullable Boolean isDeviceChoiceRequired) { |
| 179 | + ThreadUtils.checkUiThread(); |
| 180 | + |
127 | 181 | assert mDelegate != null; |
| 182 | + boolean wasDialogShown = mDialogAddedTimeMillis != null; |
| 183 | + boolean wasDialogDismissed = wasDialogShown && mDialogType == DialogType.UNKNOWN; |
| 184 | + |
| 185 | + if (mFirstServiceEventTimeMillis == null) { |
| 186 | + mFirstServiceEventTimeMillis = System.currentTimeMillis(); |
| 187 | + // TODO(b/355201070): Replace this after e2e testing with UMA recording. |
| 188 | + Log.i( |
| 189 | + TAG, |
| 190 | + "onIsDeviceChoiceRequiredChanged(%s), time since dialog added: %s millis, " |
| 191 | + + "time since observation started: %s millis", |
| 192 | + isDeviceChoiceRequired, |
| 193 | + wasDialogShown |
| 194 | + ? mFirstServiceEventTimeMillis - mDialogAddedTimeMillis |
| 195 | + : "<N/A>", |
| 196 | + mObservationStartedTimeMillis != null |
| 197 | + ? mFirstServiceEventTimeMillis - mObservationStartedTimeMillis |
| 198 | + : "<N/A>"); |
| 199 | + } |
128 | 200 |
|
129 | | - if (Boolean.TRUE.equals(isDeviceChoiceRequired)) { |
130 | | - // We expect it to happen only as the very first notification we get. Other values as |
131 | | - // first notification lead to skipping the dialog entirely. |
132 | | - assert !mIsDialogShown; |
| 201 | + if (Boolean.TRUE.equals(isDeviceChoiceRequired) && !wasDialogDismissed) { |
133 | 202 | mDialogType = DialogType.CHOICE_LAUNCH; |
134 | 203 | mDelegate.updateDialogType(DialogType.CHOICE_LAUNCH); |
135 | | - mDelegate.showDialog(); |
| 204 | + |
| 205 | + if (!wasDialogShown) { |
| 206 | + mDelegate.showDialog(); |
| 207 | + } |
136 | 208 | return; |
137 | 209 | } |
138 | 210 |
|
139 | 211 | // `isDeviceChoiceRequired` being null indicates that the backend was disconnected, and |
140 | | - // false indicates that blocking the user is not necessary anymore. In both cases we'll want |
141 | | - // to unblock the user, but based on which state the UI is in, we may show some confirmation |
142 | | - // message or not. |
143 | | - |
144 | | - if (mIsDialogShown |
145 | | - && Boolean.FALSE.equals(isDeviceChoiceRequired) |
146 | | - && mDialogType == DialogType.CHOICE_LAUNCH) { |
147 | | - // This is the normal flow, showing confirmation after the choice has been made. |
148 | | - mDialogType = DialogType.CHOICE_CONFIRM; |
149 | | - mDelegate.updateDialogType(DialogType.CHOICE_CONFIRM); |
150 | | - mSearchEngineChoiceService.notifyDeviceChoiceBlockCleared(); |
151 | | - return; |
152 | | - } |
| 212 | + // false indicates that blocking the user is not necessary anymore. In both cases we'll |
| 213 | + // want to unblock the user, but based on which state the UI is in, we may show some |
| 214 | + // confirmation message or not. |
153 | 215 |
|
154 | | - if (mIsDialogShown && mDialogType == DialogType.CHOICE_CONFIRM) { |
155 | | - // The backend is sending us some updates while we are showing the confirmation UI. We |
156 | | - // are not blocking anyway and the user can proceed, so don't do anything about it. |
157 | | - return; |
| 216 | + if (wasDialogShown && !wasDialogDismissed) { |
| 217 | + if (Boolean.FALSE.equals(isDeviceChoiceRequired) |
| 218 | + && (mDialogType == DialogType.LOADING |
| 219 | + || mDialogType == DialogType.CHOICE_LAUNCH)) { |
| 220 | + // This is the normal flow, showing confirmation after the choice has been made. |
| 221 | + mDialogType = DialogType.CHOICE_CONFIRM; |
| 222 | + mDelegate.updateDialogType(DialogType.CHOICE_CONFIRM); |
| 223 | + mSearchEngineChoiceService.notifyDeviceChoiceBlockCleared(); |
| 224 | + return; |
| 225 | + } |
| 226 | + |
| 227 | + if (mDialogType == DialogType.CHOICE_CONFIRM) { |
| 228 | + // The backend is sending us some updates while we are showing the confirmation UI. |
| 229 | + // We are not blocking and the user can proceed, so don't do anything about it. |
| 230 | + return; |
| 231 | + } |
158 | 232 | } |
159 | 233 |
|
160 | 234 | // If we get here, this is some sort of error state. Shutdown everything. |
161 | | - // Indicates that the backend was disconnected. This would make the dialog |
162 | | - // non-functional, so let's dismiss it and let the user proceed to Chrome. |
163 | | - // TODO(b/355201070): Add UMA recording. |
| 235 | + // Indicates that the backend was disconnected. This would make the dialog non-functional if |
| 236 | + // it is still shown, so let's dismiss it and let the user proceed to Chrome. |
| 237 | + // TODO(b/355201070): Add UMA recording, remove or update the log below. |
164 | 238 | Log.w( |
165 | 239 | TAG, |
166 | 240 | "Unexpected backend update received. State: " |
167 | | - + "{mIsDialogShown=%b, mDialogType=%s, isDeviceChoiceRequired=%s}", |
168 | | - mIsDialogShown, |
| 241 | + + "{wasDialogShown=%b, wasDialogDismissed=%b, mDialogType=%s, " |
| 242 | + + "isDeviceChoiceRequired=%s}", |
| 243 | + wasDialogShown, |
| 244 | + wasDialogDismissed, |
169 | 245 | mDialogType, |
170 | 246 | isDeviceChoiceRequired); |
171 | 247 | mDelegate.dismissDialog(); |
172 | 248 | destroy(); |
173 | 249 | } |
| 250 | + |
| 251 | + private void scheduleDismissOnDeviceChoiceRequiredUpdateTimeout() { |
| 252 | + if (mDialogType != DialogType.LOADING) { |
| 253 | + return; |
| 254 | + } |
| 255 | + |
| 256 | + int dialogTimeoutMillis = SearchEnginesFeatureUtils.clayBlockingDialogTimeoutMillis(); |
| 257 | + if (dialogTimeoutMillis > 0) { |
| 258 | + ThreadUtils.postOnUiThreadDelayed( |
| 259 | + () -> { |
| 260 | + if (mDialogType != DialogType.LOADING) { |
| 261 | + return; // No-op, we got an update. |
| 262 | + } |
| 263 | + |
| 264 | + assert mDelegate != null; // Unexpected if the type is still "loading". |
| 265 | + |
| 266 | + Log.w( |
| 267 | + TAG, |
| 268 | + "Timeout waiting for backend block confirmation. Deadline: %s ms", |
| 269 | + dialogTimeoutMillis); |
| 270 | + |
| 271 | + mDelegate.dismissDialog(); |
| 272 | + destroy(); |
| 273 | + }, |
| 274 | + dialogTimeoutMillis); |
| 275 | + } |
| 276 | + } |
174 | 277 | } |
0 commit comments