diff options
| author | 2020-09-18 17:43:50 +0000 | |
|---|---|---|
| committer | 2020-09-18 17:43:50 +0000 | |
| commit | 99bba2b91ee2dc43ee229fa402956ef491369e91 (patch) | |
| tree | 82b7dfbfcd93cbb8d501ea4a059d36a190407375 | |
| parent | ca43ae658aa475ba4c83e543bf5cef08325cff14 (diff) | |
| parent | 95126bcefd4b9ac156bd44d83cdb11ceac0ec496 (diff) | |
Merge "Initial version of LocationTimeZoneManagerService"
22 files changed, 3492 insertions, 5 deletions
diff --git a/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java new file mode 100644 index 000000000000..92dabe337d87 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.location.timezone.LocationTimeZoneEvent; +import android.util.IndentingPrintWriter; +import android.util.Slog; + +import com.android.internal.location.timezone.LocationTimeZoneProviderRequest; + +import java.util.Objects; + +/** + * The real, system-server side implementation of a binder call backed {@link + * LocationTimeZoneProvider}. It handles keeping track of current state, timeouts and ensuring + * events are passed to the {@link LocationTimeZoneProviderController} on the required thread. + */ +class BinderLocationTimeZoneProvider extends LocationTimeZoneProvider { + + private static final String TAG = LocationTimeZoneManagerService.TAG; + + @NonNull private final LocationTimeZoneProviderProxy mProxy; + + BinderLocationTimeZoneProvider( + @NonNull ThreadingDomain threadingDomain, + @NonNull String providerName, + @NonNull LocationTimeZoneProviderProxy proxy) { + super(threadingDomain, providerName); + mProxy = Objects.requireNonNull(proxy); + } + + @Override + void onInitialize() { + mProxy.setListener(new LocationTimeZoneProviderProxy.Listener() { + @Override + public void onReportLocationTimeZoneEvent( + @NonNull LocationTimeZoneEvent locationTimeZoneEvent) { + handleLocationTimeZoneEvent(locationTimeZoneEvent); + } + + @Override + public void onProviderBound() { + handleOnProviderBound(); + } + + @Override + public void onProviderUnbound() { + handleProviderLost("onProviderUnbound()"); + } + }); + } + + private void handleProviderLost(String reason) { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + ProviderState currentState = mCurrentState.get(); + switch (currentState.stateEnum) { + case PROVIDER_STATE_ENABLED: { + // Losing a remote provider is treated as becoming uncertain. + String msg = "handleProviderLost reason=" + reason + + ", mProviderName=" + mProviderName + + ", currentState=" + currentState; + debugLog(msg); + // This is an unusual PROVIDER_STATE_ENABLED state because event == null + ProviderState newState = currentState.newState( + PROVIDER_STATE_ENABLED, null, currentState.currentUserConfiguration, + msg); + setCurrentState(newState, true); + break; + } + case PROVIDER_STATE_DISABLED: { + debugLog("handleProviderLost reason=" + reason + + ", mProviderName=" + mProviderName + + ", currentState=" + currentState + + ": No state change required, provider is disabled."); + break; + } + case PROVIDER_STATE_PERM_FAILED: { + debugLog("handleProviderLost reason=" + reason + + ", mProviderName=" + mProviderName + + ", currentState=" + currentState + + ": No state change required, provider is perm failed."); + break; + } + default: { + throw new IllegalStateException("Unknown currentState=" + currentState); + } + } + } + } + + private void handleOnProviderBound() { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + ProviderState currentState = mCurrentState.get(); + switch (currentState.stateEnum) { + case PROVIDER_STATE_ENABLED: { + debugLog("handleOnProviderBound mProviderName=" + mProviderName + + ", currentState=" + currentState + ": Provider is enabled."); + break; + } + case PROVIDER_STATE_DISABLED: { + debugLog("handleOnProviderBound mProviderName=" + mProviderName + + ", currentState=" + currentState + ": Provider is disabled."); + break; + } + case PROVIDER_STATE_PERM_FAILED: { + debugLog("handleOnProviderBound" + + ", mProviderName=" + mProviderName + + ", currentState=" + currentState + + ": No state change required, provider is perm failed."); + break; + } + default: { + throw new IllegalStateException("Unknown currentState=" + currentState); + } + } + } + } + + @Override + void onEnable() { + // Set a request on the proxy - it will be sent immediately if the service is bound, + // or will be sent as soon as the service becomes bound. + // TODO(b/152744911): Decide whether to send a timeout so the provider knows how long + // it has to generate the first event before it could be bypassed. + LocationTimeZoneProviderRequest request = + new LocationTimeZoneProviderRequest.Builder() + .setReportLocationTimeZone(true) + .build(); + mProxy.setRequest(request); + } + + @Override + void onDisable() { + LocationTimeZoneProviderRequest request = + new LocationTimeZoneProviderRequest.Builder() + .setReportLocationTimeZone(false) + .build(); + mProxy.setRequest(request); + } + + @Override + void logWarn(String msg) { + Slog.w(TAG, msg); + } + + @Override + public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) { + synchronized (mSharedLock) { + ipw.println("{BinderLocationTimeZoneProvider}"); + ipw.println("mProviderName=" + mProviderName); + ipw.println("mCurrentState=" + mCurrentState); + ipw.println("mProxy=" + mProxy); + + ipw.println("State history:"); + ipw.increaseIndent(); + mCurrentState.dump(ipw); + ipw.decreaseIndent(); + + ipw.println("Proxy details:"); + ipw.increaseIndent(); + mProxy.dump(ipw, args); + ipw.decreaseIndent(); + } + } + + @Override + public String toString() { + synchronized (mSharedLock) { + return "BinderLocationTimeZoneProvider{" + + "mProviderName=" + mProviderName + + "mCurrentState=" + mCurrentState + + "mProxy=" + mProxy + + '}'; + } + } + + /** + * Passes the supplied simulation / testing event to the current proxy iff the proxy is a + * {@link SimulatedLocationTimeZoneProviderProxy}. If not, the event is logged but discarded. + */ + void simulateBinderProviderEvent(SimulatedBinderProviderEvent event) { + if (!(mProxy instanceof SimulatedLocationTimeZoneProviderProxy)) { + Slog.w(TAG, mProxy + " is not a " + SimulatedLocationTimeZoneProviderProxy.class + + ", event=" + event); + return; + } + ((SimulatedLocationTimeZoneProviderProxy) mProxy).simulate(event); + } +} diff --git a/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java b/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java new file mode 100644 index 000000000000..cd9aa2fd6a5b --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; + +import com.android.server.LocalServices; +import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion; +import com.android.server.timezonedetector.TimeZoneDetectorInternal; + +/** + * The real implementation of {@link LocationTimeZoneProviderController.Callback} used by + * {@link ControllerImpl} to interact with other server components. + */ +class ControllerCallbackImpl extends LocationTimeZoneProviderController.Callback { + + ControllerCallbackImpl(@NonNull ThreadingDomain threadingDomain) { + super(threadingDomain); + } + + @Override + void suggest(@NonNull GeolocationTimeZoneSuggestion suggestion) { + mThreadingDomain.assertCurrentThread(); + + TimeZoneDetectorInternal timeZoneDetector = + LocalServices.getService(TimeZoneDetectorInternal.class); + timeZoneDetector.suggestGeolocationTimeZone(suggestion); + } +} diff --git a/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java b/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java new file mode 100644 index 000000000000..2e2481c30f22 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; + +import com.android.server.LocalServices; +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.TimeZoneDetectorInternal; + +import java.util.Objects; + +/** + * The real implementation of {@link LocationTimeZoneProviderController.Environment} used by + * {@link ControllerImpl} to interact with other server components. + */ +class ControllerEnvironmentImpl extends LocationTimeZoneProviderController.Environment { + + @NonNull private final TimeZoneDetectorInternal mTimeZoneDetectorInternal; + @NonNull private final LocationTimeZoneProviderController mController; + + ControllerEnvironmentImpl(@NonNull ThreadingDomain threadingDomain, + @NonNull LocationTimeZoneProviderController controller) { + super(threadingDomain); + mController = Objects.requireNonNull(controller); + mTimeZoneDetectorInternal = LocalServices.getService(TimeZoneDetectorInternal.class); + + // Listen for configuration changes. + mTimeZoneDetectorInternal.addConfigurationListener( + () -> mThreadingDomain.post(mController::onConfigChanged)); + } + + @Override + ConfigurationInternal getCurrentUserConfigurationInternal() { + return mTimeZoneDetectorInternal.getCurrentUserConfigurationInternal(); + } +} diff --git a/services/core/java/com/android/server/location/timezone/ControllerImpl.java b/services/core/java/com/android/server/location/timezone/ControllerImpl.java new file mode 100644 index 000000000000..e31cfc4e8b40 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/ControllerImpl.java @@ -0,0 +1,420 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN; + +import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog; +import static com.android.server.location.timezone.LocationTimeZoneManagerService.warnLog; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.location.timezone.LocationTimeZoneEvent; +import android.util.IndentingPrintWriter; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.location.timezone.ThreadingDomain.SingleRunnableQueue; +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion; + +import java.time.Duration; +import java.util.Objects; + +/** + * A real implementation of {@link LocationTimeZoneProviderController} that supports a single + * {@link LocationTimeZoneProvider}. + * + * TODO(b/152744911): This implementation currently only supports a single ("primary") provider. + * Support for a secondary provider will be added in a later commit. + */ +class ControllerImpl extends LocationTimeZoneProviderController { + + @VisibleForTesting + static final Duration UNCERTAINTY_DELAY = Duration.ofMinutes(5); + + @NonNull private final LocationTimeZoneProvider mProvider; + @NonNull private final SingleRunnableQueue mDelayedSuggestionQueue; + + @GuardedBy("mSharedLock") + // Non-null after initialize() + private ConfigurationInternal mCurrentUserConfiguration; + + @GuardedBy("mSharedLock") + // Non-null after initialize() + private Environment mEnvironment; + + @GuardedBy("mSharedLock") + // Non-null after initialize() + private Callback mCallback; + + /** + * Contains any currently pending suggestion on {@link #mDelayedSuggestionQueue}, if there is + * one. + */ + @GuardedBy("mSharedLock") + @Nullable + private GeolocationTimeZoneSuggestion mPendingSuggestion; + + /** Contains the last suggestion actually made, if there is one. */ + @GuardedBy("mSharedLock") + @Nullable + private GeolocationTimeZoneSuggestion mLastSuggestion; + + ControllerImpl(@NonNull ThreadingDomain threadingDomain, + @NonNull LocationTimeZoneProvider provider) { + super(threadingDomain); + mDelayedSuggestionQueue = threadingDomain.createSingleRunnableQueue(); + mProvider = Objects.requireNonNull(provider); + } + + @Override + void initialize(@NonNull Environment environment, @NonNull Callback callback) { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + debugLog("initialize()"); + mEnvironment = Objects.requireNonNull(environment); + mCallback = Objects.requireNonNull(callback); + mCurrentUserConfiguration = environment.getCurrentUserConfigurationInternal(); + + mProvider.initialize(ControllerImpl.this::onProviderStateChange); + enableOrDisableProvider(mCurrentUserConfiguration); + } + } + + @Override + void onConfigChanged() { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + debugLog("onEnvironmentConfigChanged()"); + + ConfigurationInternal oldConfig = mCurrentUserConfiguration; + ConfigurationInternal newConfig = mEnvironment.getCurrentUserConfigurationInternal(); + mCurrentUserConfiguration = newConfig; + + if (!newConfig.equals(oldConfig)) { + if (newConfig.getUserId() != oldConfig.getUserId()) { + // If the user changed, disable the provider if needed. It may be re-enabled for + // the new user below if their settings allow. + debugLog("User changed. old=" + oldConfig.getUserId() + + ", new=" + newConfig.getUserId()); + debugLog("Disabling LocationTimeZoneProviders as needed"); + if (mProvider.getCurrentState().stateEnum == PROVIDER_STATE_ENABLED) { + mProvider.disable(); + } + } + + enableOrDisableProvider(newConfig); + } + } + } + + @GuardedBy("mSharedLock") + private void enableOrDisableProvider(@NonNull ConfigurationInternal configuration) { + ProviderState providerState = mProvider.getCurrentState(); + boolean geoDetectionEnabled = configuration.getGeoDetectionEnabledBehavior(); + boolean providerWasEnabled = providerState.stateEnum == PROVIDER_STATE_ENABLED; + if (geoDetectionEnabled) { + switch (providerState.stateEnum) { + case PROVIDER_STATE_DISABLED: { + debugLog("Enabling " + mProvider); + mProvider.enable(configuration); + break; + } + case PROVIDER_STATE_ENABLED: { + debugLog("No need to enable " + mProvider + ": already enabled"); + break; + } + case PROVIDER_STATE_PERM_FAILED: { + debugLog("Unable to enable " + mProvider + ": it is perm failed"); + break; + } + default: + warnLog("Unknown provider state: " + mProvider); + break; + } + } else { + switch (providerState.stateEnum) { + case PROVIDER_STATE_DISABLED: { + debugLog("No need to disable " + mProvider + ": already enabled"); + break; + } + case PROVIDER_STATE_ENABLED: { + debugLog("Disabling " + mProvider); + mProvider.disable(); + break; + } + case PROVIDER_STATE_PERM_FAILED: { + debugLog("Unable to disable " + mProvider + ": it is perm failed"); + break; + } + default: { + warnLog("Unknown provider state: " + mProvider); + break; + } + } + } + + boolean isProviderEnabled = + mProvider.getCurrentState().stateEnum == PROVIDER_STATE_ENABLED; + + if (isProviderEnabled) { + if (!providerWasEnabled) { + // When a provider has first been enabled, we allow it some time for it to + // initialize. + // This sets up an empty suggestion to trigger if no explicit "certain" or + // "uncertain" suggestion preempts it within UNCERTAINTY_DELAY. If, for some reason, + // the provider does provide any events then this scheduled suggestion will ensure + // the controller makes at least an uncertain suggestion. + suggestDelayed(createEmptySuggestion( + "No event received in delay=" + UNCERTAINTY_DELAY), UNCERTAINTY_DELAY); + } + } else { + // Clear any queued suggestions. + clearDelayedSuggestion(); + + // If the provider is now not enabled, and a previous "certain" suggestion has been + // made, then a new "uncertain" suggestion must be made to indicate the provider no + // longer has an opinion and will not be sending updates. + if (mLastSuggestion != null && mLastSuggestion.getZoneIds() != null) { + suggestImmediate(createEmptySuggestion("")); + } + } + } + + void onProviderStateChange(@NonNull ProviderState providerState) { + mThreadingDomain.assertCurrentThread(); + assertProviderKnown(providerState.provider); + + synchronized (mSharedLock) { + switch (providerState.stateEnum) { + case PROVIDER_STATE_DISABLED: { + // This should never happen: entering disabled does not trigger an event. + warnLog("onProviderStateChange: Unexpected state change for disabled provider," + + " providerState=" + providerState); + break; + } + case PROVIDER_STATE_ENABLED: { + // Entering enabled does not trigger an event, so this only happens if an event + // is received while the provider is enabled. + debugLog("onProviderStateChange: Received notification of an event while" + + " enabled, providerState=" + providerState); + providerEnabledProcessEvent(providerState); + break; + } + case PROVIDER_STATE_PERM_FAILED: { + debugLog("Received notification of permanent failure for" + + " provider=" + providerState); + GeolocationTimeZoneSuggestion suggestion = createEmptySuggestion( + "provider=" + providerState.provider + + " permanently failed: " + providerState); + suggestImmediate(suggestion); + break; + } + default: { + warnLog("onProviderStateChange: Unexpected providerState=" + providerState); + } + } + } + } + + private void assertProviderKnown(LocationTimeZoneProvider provider) { + if (provider != mProvider) { + throw new IllegalArgumentException("Unknown provider: " + provider); + } + } + + /** + * Called when a provider has changed state but just moved from a PROVIDER_STATE_ENABLED state + * to another PROVIDER_STATE_ENABLED state, usually as a result of a new {@link + * LocationTimeZoneEvent} being received. There are some cases where event can be null. + */ + private void providerEnabledProcessEvent(@NonNull ProviderState providerState) { + LocationTimeZoneEvent event = providerState.event; + if (event == null) { + // Implicit uncertainty, i.e. where the provider is enabled, but a problem has been + // detected without having received an event. For example, if the process has detected + // the loss of a binder-based provider. This is treated like explicit uncertainty, i.e. + // where the provider has explicitly told this process it is uncertain. + scheduleUncertainSuggestionIfNeeded(null); + return; + } + + // Consistency check for user. This may be possible as there are various races around + // current user switches. + if (!Objects.equals(event.getUserHandle(), mCurrentUserConfiguration.getUserHandle())) { + warnLog("Using event=" + event + " from a different user=" + + mCurrentUserConfiguration); + } + + if (!mCurrentUserConfiguration.getGeoDetectionEnabledBehavior()) { + // This should not happen: the provider should not be in an enabled state if the user + // does not have geodetection enabled. + warnLog("Provider=" + providerState + " is enabled, but currentUserConfiguration=" + + mCurrentUserConfiguration + " suggests it shouldn't be."); + } + + switch (event.getEventType()) { + case EVENT_TYPE_PERMANENT_FAILURE: { + // This shouldn't happen. Providers cannot be enabled and have this event. + warnLog("Provider=" + providerState + + " is enabled, but event suggests it shouldn't be"); + break; + } + case EVENT_TYPE_UNCERTAIN: { + scheduleUncertainSuggestionIfNeeded(event); + break; + } + case EVENT_TYPE_SUCCESS: { + GeolocationTimeZoneSuggestion suggestion = + new GeolocationTimeZoneSuggestion(event.getTimeZoneIds()); + suggestion.addDebugInfo("Event received provider=" + mProvider.getName() + + ", event=" + event); + // Rely on the receiver to dedupe events. It is better to over-communicate. + suggestImmediate(suggestion); + break; + } + default: { + warnLog("Unknown eventType=" + event.getEventType()); + break; + } + } + } + + /** + * Indicates a provider has become uncertain with the event (if any) received that indicates + * that. + * + * <p>Providers are expected to report their uncertainty as soon as they become uncertain, as + * this enables the most flexibility for the controller to enable other providers when there are + * multiple ones. The controller is therefore responsible for deciding when to make a + * "uncertain" suggestion. + * + * <p>This method schedules an "uncertain" suggestion (if one isn't already scheduled) to be + * made later if nothing else preempts it. It can be preempted if the provider becomes certain + * (or does anything else that calls {@link #suggestImmediate(GeolocationTimeZoneSuggestion)}) + * within UNCERTAINTY_DELAY. Preemption causes the scheduled "uncertain" event to be cancelled. + * If the provider repeatedly sends uncertainty events within UNCERTAINTY_DELAY, those events + * are effectively ignored (i.e. the timer is not reset each time). + */ + private void scheduleUncertainSuggestionIfNeeded(@Nullable LocationTimeZoneEvent event) { + if (mPendingSuggestion == null || mPendingSuggestion.getZoneIds() != null) { + GeolocationTimeZoneSuggestion suggestion = createEmptySuggestion( + "provider=" + mProvider + " became uncertain, event=" + event); + suggestDelayed(suggestion, UNCERTAINTY_DELAY); + } + } + + @Override + public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) { + synchronized (mSharedLock) { + ipw.println("LocationTimeZoneProviderController:"); + + ipw.increaseIndent(); // level 1 + ipw.println("mCurrentUserConfiguration=" + mCurrentUserConfiguration); + ipw.println("mPendingSuggestion=" + mPendingSuggestion); + ipw.println("mLastSuggestion=" + mLastSuggestion); + + ipw.println("Provider:"); + ipw.increaseIndent(); // level 2 + mProvider.dump(ipw, args); + ipw.decreaseIndent(); // level 2 + + ipw.decreaseIndent(); // level 1 + } + } + + /** Sends an immediate suggestion, cancelling any pending suggestion. */ + @GuardedBy("mSharedLock") + private void suggestImmediate(@NonNull GeolocationTimeZoneSuggestion suggestion) { + debugLog("suggestImmediate: Executing suggestion=" + suggestion); + mDelayedSuggestionQueue.runSynchronously(() -> mCallback.suggest(suggestion)); + mPendingSuggestion = null; + mLastSuggestion = suggestion; + } + + /** Clears any pending suggestion. */ + @GuardedBy("mSharedLock") + private void clearDelayedSuggestion() { + mDelayedSuggestionQueue.cancel(); + mPendingSuggestion = null; + } + + + /** + * Schedules a delayed suggestion. There can only be one delayed suggestion at a time. + * If there is a pending scheduled suggestion equal to the one passed, it will not be replaced. + * Replacing a previous delayed suggestion has the effect of cancelling the timeout associated + * with that previous suggestion. + */ + @GuardedBy("mSharedLock") + private void suggestDelayed(@NonNull GeolocationTimeZoneSuggestion suggestion, + @NonNull Duration delay) { + Objects.requireNonNull(suggestion); + Objects.requireNonNull(delay); + + if (Objects.equals(mPendingSuggestion, suggestion)) { + // Do not reset the timer. + debugLog("suggestDelayed: Suggestion=" + suggestion + " is equal to existing." + + " Not scheduled."); + return; + } + + debugLog("suggestDelayed: Scheduling suggestion=" + suggestion); + mPendingSuggestion = suggestion; + + mDelayedSuggestionQueue.runDelayed(() -> { + debugLog("suggestDelayed: Executing suggestion=" + suggestion); + mCallback.suggest(suggestion); + mPendingSuggestion = null; + mLastSuggestion = suggestion; + }, delay.toMillis()); + } + + private static GeolocationTimeZoneSuggestion createEmptySuggestion(String reason) { + GeolocationTimeZoneSuggestion suggestion = new GeolocationTimeZoneSuggestion(null); + suggestion.addDebugInfo(reason); + return suggestion; + } + + /** + * Asynchronously passes a {@link SimulatedBinderProviderEvent] to the appropriate provider. + * If the provider name does not match a known provider, then the event is logged and discarded. + */ + void simulateBinderProviderEvent(SimulatedBinderProviderEvent event) { + if (!Objects.equals(mProvider.getName(), event.getProviderName())) { + warnLog("Unable to process simulated binder provider event," + + " unknown providerName in event=" + event); + return; + } + if (!(mProvider instanceof BinderLocationTimeZoneProvider)) { + warnLog("Unable to process simulated binder provider event," + + " provider is not a " + BinderLocationTimeZoneProvider.class + + ", event=" + event); + return; + } + ((BinderLocationTimeZoneProvider) mProvider).simulateBinderProviderEvent(event); + } +} diff --git a/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java b/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java new file mode 100644 index 000000000000..17e719ed2cb0 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; +import android.os.Handler; + +import java.util.Objects; + +/** + * The real implementation of {@link ThreadingDomain} that uses a {@link Handler}. + */ +final class HandlerThreadingDomain extends ThreadingDomain { + + @NonNull private final Handler mHandler; + + HandlerThreadingDomain(Handler handler) { + mHandler = Objects.requireNonNull(handler); + } + + /** + * Returns the {@link Handler} associated with this threading domain. The same {@link Handler} + * may be associated with multiple threading domains, e.g. multiple threading domains could + * choose to use the {@link com.android.server.FgThread} handler. + * + * <p>If you find yourself making this public because you need a {@link Handler}, then it may + * cause problems with testability. Try to avoid using this method and use methods like {@link + * #post(Runnable)} instead. + */ + @NonNull + Handler getHandler() { + return mHandler; + } + + @NonNull + Thread getThread() { + return getHandler().getLooper().getThread(); + } + + @Override + void post(@NonNull Runnable r) { + getHandler().post(r); + } + + @Override + void postDelayed(@NonNull Runnable r, long delayMillis) { + getHandler().postDelayed(r, delayMillis); + } + + @Override + void postDelayed(Runnable r, Object token, long delayMillis) { + getHandler().postDelayed(r, token, delayMillis); + } + + @Override + void removeQueuedRunnables(Object token) { + getHandler().removeCallbacksAndMessages(token); + } +} diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java new file mode 100644 index 000000000000..238f999ff8a6 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.os.Binder; +import android.os.ResultReceiver; +import android.os.ShellCallback; +import android.os.SystemProperties; +import android.util.IndentingPrintWriter; +import android.util.Log; +import android.util.Slog; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.util.DumpUtils; +import com.android.server.FgThread; +import com.android.server.SystemService; +import com.android.server.timezonedetector.TimeZoneDetectorInternal; +import com.android.server.timezonedetector.TimeZoneDetectorService; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.Objects; + +/** + * A service class that acts as a container for the {@link LocationTimeZoneProviderController}, + * which determines what {@link com.android.server.timezonedetector.GeolocationTimeZoneSuggestion} + * are made to the {@link TimeZoneDetectorInternal}, and the {@link LocationTimeZoneProvider}s that + * offer {@link android.location.timezone.LocationTimeZoneEvent}s. + * + * TODO(b/152744911): This implementation currently only supports a primary provider. Support for a + * secondary provider must be added in a later commit. + * + * <p>Implementation details: + * + * <p>For simplicity, with the exception of a few outliers like {@link #dump}, all processing in + * this service (and package-private helper objects) takes place on a single thread / handler, the + * one indicated by {@link ThreadingDomain}. Because methods like {@link #dump} can be invoked on + * another thread, the service and its related objects must still be thread-safe. + * + * <p>For testing / reproduction of bugs, it is possible to put providers into "simulation + * mode" where the real binder clients are replaced by {@link + * SimulatedLocationTimeZoneProviderProxy}. This means that the real client providers are never + * bound (ensuring no real location events will be received) and simulated events / behaviors + * can be injected via the command line. To enter simulation mode for a provider, use + * "{@code adb shell setprop persist.sys.location_tz_simulation_mode.<provider name> 1}" and reboot. + * e.g. "{@code adb shell setprop persist.sys.location_tz_simulation_mode.primary 1}}" + * Then use "{@code adb shell cmd location_time_zone_manager help}" for injection. Set the system + * properties to "0" and reboot to return to exit simulation mode. + */ +public class LocationTimeZoneManagerService extends Binder { + + /** + * Controls lifecycle of the {@link LocationTimeZoneManagerService}. + */ + public static class Lifecycle extends SystemService { + + private LocationTimeZoneManagerService mService; + + public Lifecycle(@NonNull Context context) { + super(Objects.requireNonNull(context)); + } + + @Override + public void onStart() { + if (TimeZoneDetectorService.GEOLOCATION_TIME_ZONE_DETECTION_ENABLED) { + Context context = getContext(); + mService = new LocationTimeZoneManagerService(context); + + // The service currently exposes no LocalService or Binder API, but it extends + // Binder and is registered as a binder service so it can receive shell commands. + publishBinderService("location_time_zone_manager", mService); + } else { + Slog.i(TAG, getClass() + " is compile-time disabled"); + } + } + + @Override + public void onBootPhase(int phase) { + if (TimeZoneDetectorService.GEOLOCATION_TIME_ZONE_DETECTION_ENABLED) { + if (phase == PHASE_SYSTEM_SERVICES_READY) { + // The location service must be functioning after this boot phase. + mService.onSystemReady(); + } else if (phase == PHASE_THIRD_PARTY_APPS_CAN_START) { + // Some providers rely on non-platform code (e.g. gcore), so we wait to + // initialize providers until third party code is allowed to run. + mService.onSystemThirdPartyAppsCanStart(); + } + } + } + } + + static final String TAG = "LocationTZDetector"; + + static final String PRIMARY_PROVIDER_NAME = "primary"; + + private static final String SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX = + "persist.sys.location_tz_simulation_mode."; + + private static final String ATTRIBUTION_TAG = "LocationTimeZoneService"; + + private static final String PRIMARY_LOCATION_TIME_ZONE_SERVICE_ACTION = + "com.android.location.timezone.service.v1.PrimaryLocationTimeZoneProvider"; + + + @NonNull private final Context mContext; + + /** + * The {@link ThreadingDomain} used to supply the {@link android.os.Handler} and shared lock + * object used by the controller and related components. + * + * <p>Most operations are executed on the associated handler thread <em>but not all</em>, hence + * the requirement for additional synchronization using a shared lock. + */ + @NonNull private final ThreadingDomain mThreadingDomain; + + /** The shared lock from {@link #mThreadingDomain}. */ + @NonNull private final Object mSharedLock; + + // Lazily initialized. Non-null and effectively final after onSystemThirdPartyAppsCanStart(). + @GuardedBy("mSharedLock") + private ControllerImpl mLocationTimeZoneDetectorController; + + LocationTimeZoneManagerService(Context context) { + mContext = context.createAttributionContext(ATTRIBUTION_TAG); + mThreadingDomain = new HandlerThreadingDomain(FgThread.getHandler()); + mSharedLock = mThreadingDomain.getLockObject(); + } + + void onSystemReady() { + // Called on an arbitrary thread during initialization. + synchronized (mSharedLock) { + // TODO(b/152744911): LocationManagerService watches for packages disappearing. Need to + // do anything here? + + // TODO(b/152744911): LocationManagerService watches for foreground app changes. Need to + // do anything here? + // TODO(b/152744911): LocationManagerService watches screen state. Need to do anything + // here? + } + } + + void onSystemThirdPartyAppsCanStart() { + // Called on an arbitrary thread during initialization. + synchronized (mSharedLock) { + LocationTimeZoneProvider primary = createPrimaryProvider(); + mLocationTimeZoneDetectorController = new ControllerImpl(mThreadingDomain, primary); + ControllerCallbackImpl callback = new ControllerCallbackImpl(mThreadingDomain); + ControllerEnvironmentImpl environment = new ControllerEnvironmentImpl( + mThreadingDomain, mLocationTimeZoneDetectorController); + + // Initialize the controller on the mThreadingDomain thread: this ensures that the + // ThreadingDomain requirements for the controller / environment methods are honored. + mThreadingDomain.post(() -> + mLocationTimeZoneDetectorController.initialize(environment, callback)); + } + } + + private LocationTimeZoneProvider createPrimaryProvider() { + LocationTimeZoneProviderProxy proxy; + if (isInSimulationMode(PRIMARY_PROVIDER_NAME)) { + proxy = new SimulatedLocationTimeZoneProviderProxy(mContext, mThreadingDomain); + } else { + // TODO Uncomment this code in a later commit. + throw new UnsupportedOperationException("Not implemented"); + /* + proxy = RealLocationTimeZoneProviderProxy.createAndRegister( + mContext, + mThreadingDomain, + PRIMARY_LOCATION_TIME_ZONE_SERVICE_ACTION, + com.android.internal.R.bool.config_enablePrimaryLocationTimeZoneOverlay, + com.android.internal.R.string.config_primaryLocationTimeZoneProviderPackageName + ); + */ + } + return createLocationTimeZoneProvider(PRIMARY_PROVIDER_NAME, proxy); + } + + private boolean isInSimulationMode(String providerName) { + return SystemProperties.getBoolean( + SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX + providerName, false); + } + + private LocationTimeZoneProvider createLocationTimeZoneProvider( + @NonNull String providerName, @NonNull LocationTimeZoneProviderProxy proxy) { + LocationTimeZoneProvider provider; + if (proxy != null) { + debugLog("LocationTimeZoneProvider found for providerName=" + providerName); + provider = new BinderLocationTimeZoneProvider(mThreadingDomain, + providerName, proxy); + } else { + debugLog("No LocationTimeZoneProvider found for providerName=" + providerName + + ": stubbing"); + provider = new NullLocationTimeZoneProvider(mThreadingDomain, providerName); + } + return provider; + } + + @Override + public void onShellCommand(FileDescriptor in, FileDescriptor out, + FileDescriptor err, String[] args, ShellCallback callback, + ResultReceiver resultReceiver) { + (new LocationTimeZoneManagerShellCommand(this)).exec( + this, in, out, err, args, callback, resultReceiver); + } + + /** + * Asynchronously passes a {@link SimulatedBinderProviderEvent] to the appropriate provider. + * The device must be in simulation mode, otherwise an {@link IllegalStateException} will be + * thrown. + */ + void simulateBinderProviderEvent(SimulatedBinderProviderEvent event) + throws IllegalStateException { + if (!isInSimulationMode(event.getProviderName())) { + throw new IllegalStateException("Use \"setprop " + + SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX + event.getProviderName() + + " 1\" and reboot before injecting simulated binder events."); + } + mThreadingDomain.post( + () -> mLocationTimeZoneDetectorController.simulateBinderProviderEvent(event)); + } + + @Override + protected void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw, + @Nullable String[] args) { + if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return; + + IndentingPrintWriter ipw = new IndentingPrintWriter(pw); + // Called on an arbitrary thread at any time. + synchronized (mSharedLock) { + ipw.println("LocationTimeZoneManagerService:"); + ipw.increaseIndent(); + if (mLocationTimeZoneDetectorController == null) { + ipw.println("{Uninitialized}"); + } else { + mLocationTimeZoneDetectorController.dump(ipw, args); + } + ipw.decreaseIndent(); + } + } + + static void debugLog(String msg) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Slog.d(TAG, msg); + } + } + + static void warnLog(String msg) { + if (Log.isLoggable(TAG, Log.WARN)) { + Slog.w(TAG, msg); + } + } +} diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java new file mode 100644 index 000000000000..7c3b891743cc --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import android.os.ShellCommand; + +import java.io.PrintWriter; + +/** Implements the shell command interface for {@link LocationTimeZoneManagerService}. */ +class LocationTimeZoneManagerShellCommand extends ShellCommand { + + private final LocationTimeZoneManagerService mService; + + LocationTimeZoneManagerShellCommand(LocationTimeZoneManagerService service) { + mService = service; + } + + @Override + public int onCommand(String cmd) { + if (cmd == null) { + return handleDefaultCommands(cmd); + } + + switch (cmd) { + case "simulate_binder": { + return runSimulateBinderEvent(); + } + default: { + return handleDefaultCommands(cmd); + } + } + } + + private int runSimulateBinderEvent() { + PrintWriter outPrintWriter = getOutPrintWriter(); + + SimulatedBinderProviderEvent simulatedProviderBinderEvent; + try { + simulatedProviderBinderEvent = SimulatedBinderProviderEvent.createFromArgs(this); + } catch (IllegalArgumentException e) { + outPrintWriter.println("Error: " + e.getMessage()); + return 1; + } + + outPrintWriter.println("Injecting: " + simulatedProviderBinderEvent); + try { + mService.simulateBinderProviderEvent(simulatedProviderBinderEvent); + } catch (IllegalStateException e) { + outPrintWriter.println("Error: " + e.getMessage()); + return 2; + } + return 0; + } + + @Override + public void onHelp() { + final PrintWriter pw = getOutPrintWriter(); + pw.println("Location Time Zone Manager (location_time_zone_manager) commands:"); + pw.println(" help"); + pw.println(" Print this help text."); + pw.println(" simulate_binder"); + pw.println(" <simulated provider binder event>"); + pw.println(); + SimulatedBinderProviderEvent.printCommandLineOpts(pw); + pw.println(); + } +} diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java new file mode 100644 index 000000000000..3743779b20c9 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java @@ -0,0 +1,511 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN; + +import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.location.timezone.LocationTimeZoneEvent; +import android.os.Handler; +import android.os.SystemClock; + +import com.android.internal.annotations.GuardedBy; +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.Dumpable; +import com.android.server.timezonedetector.ReferenceWithHistory; + +import java.util.Objects; + +/** + * A facade used by the {@link LocationTimeZoneProviderController} to interact with a location time + * zone provider. The provider could have a binder implementation with logic running in another + * process, or could be a stubbed instance when no real provider is registered. + * + * <p>The provider is supplied with a {@link ProviderListener} via {@link + * #initialize(ProviderListener)}. This enables it to communicates asynchronous detection / error + * events back to the {@link LocationTimeZoneProviderController} via the {@link + * ProviderListener#onProviderStateChange} method. This call must be made on the + * {@link Handler} thread from the {@link ThreadingDomain} passed to the constructor. + * + * <p>All incoming calls from the controller except for {@link + * LocationTimeZoneProvider#dump(android.util.IndentingPrintWriter, String[])} will be made on the + * {@link Handler} thread of the {@link ThreadingDomain} passed to the constructor. + */ +abstract class LocationTimeZoneProvider implements Dumpable { + + /** + * Listener interface used by the {@link LocationTimeZoneProviderController} to register an + * interest in provider events. + */ + interface ProviderListener { + /** + * Indicated that a provider changed states. The {@code providerState} indicates which one + */ + void onProviderStateChange(@NonNull ProviderState providerState); + } + + /** + * Information about the provider's current state. + */ + static class ProviderState { + + @IntDef({ PROVIDER_STATE_UNKNOWN, PROVIDER_STATE_ENABLED, PROVIDER_STATE_DISABLED, + PROVIDER_STATE_PERM_FAILED }) + @interface ProviderStateEnum {} + + /** + * Uninitialized value. Must not be used afte {@link LocationTimeZoneProvider#initialize}. + */ + static final int PROVIDER_STATE_UNKNOWN = 0; + + /** + * The provider is currently enabled. + */ + static final int PROVIDER_STATE_ENABLED = 1; + + /** + * The provider is currently disabled. + * This is the state after {@link #initialize} is called. + */ + static final int PROVIDER_STATE_DISABLED = 2; + + /** + * The provider has failed and cannot be re-enabled. + * + * Providers may enter this state after a provider is enabled. + */ + static final int PROVIDER_STATE_PERM_FAILED = 3; + + /** The {@link LocationTimeZoneProvider} the state is for. */ + public final @NonNull LocationTimeZoneProvider provider; + + /** The state enum value of the current state. */ + public final @ProviderStateEnum int stateEnum; + + /** + * The last {@link LocationTimeZoneEvent} received. Only populated when {@link #stateEnum} + * is {@link #PROVIDER_STATE_ENABLED}, but it can be {@code null} then too if no event has + * yet been received. + */ + @Nullable public final LocationTimeZoneEvent event; + + /** + * The user configuration associated with the current state. Only and always present when + * {@link #stateEnum} is {@link #PROVIDER_STATE_ENABLED}. + */ + @Nullable public final ConfigurationInternal currentUserConfiguration; + + /** + * The time according to the elapsed realtime clock when the provider entered the current + * state. Included for debugging, not used for equality. + */ + private final long mStateEntryTimeMillis; + + /** + * Debug information providing context for the transition to this state. Included for + * debugging, not used for equality. + */ + @Nullable private final String mDebugInfo; + + + private ProviderState(@NonNull LocationTimeZoneProvider provider, + @ProviderStateEnum int stateEnum, @Nullable LocationTimeZoneEvent event, + @Nullable ConfigurationInternal currentUserConfiguration, + @Nullable String debugInfo) { + this.provider = Objects.requireNonNull(provider); + this.stateEnum = stateEnum; + this.event = event; + this.currentUserConfiguration = currentUserConfiguration; + this.mStateEntryTimeMillis = SystemClock.elapsedRealtime(); + this.mDebugInfo = debugInfo; + } + + /** Creates the bootstrap state, uses {@link #PROVIDER_STATE_UNKNOWN}. */ + static ProviderState createStartingState( + @NonNull LocationTimeZoneProvider provider) { + return new ProviderState( + provider, PROVIDER_STATE_UNKNOWN, null, null, "Initial state"); + } + + /** + * Create a new state from this state. Validates that the state transition is valid + * and that the required parameters for the new state are present / absent. + */ + ProviderState newState(@ProviderStateEnum int newStateEnum, + @Nullable LocationTimeZoneEvent event, + @Nullable ConfigurationInternal currentUserConfig, + @Nullable String debugInfo) { + + // Check valid "from" transitions. + switch (this.stateEnum) { + case PROVIDER_STATE_UNKNOWN: { + if (newStateEnum != PROVIDER_STATE_DISABLED) { + throw new IllegalArgumentException( + "Must transition from " + prettyPrintStateEnum( + PROVIDER_STATE_UNKNOWN) + + " to " + prettyPrintStateEnum(PROVIDER_STATE_DISABLED)); + } + break; + } + case PROVIDER_STATE_DISABLED: + case PROVIDER_STATE_ENABLED: { + // These can go to each other or PROVIDER_STATE_PERM_FAILED. + break; + } + case PROVIDER_STATE_PERM_FAILED: { + throw new IllegalArgumentException("Illegal transition out of " + + prettyPrintStateEnum(PROVIDER_STATE_UNKNOWN)); + } + default: { + throw new IllegalArgumentException("Invalid this.stateEnum=" + this.stateEnum); + } + } + + // Validate "to" transitions / arguments. + switch (newStateEnum) { + case PROVIDER_STATE_UNKNOWN: { + throw new IllegalArgumentException("Cannot transition to " + + prettyPrintStateEnum(PROVIDER_STATE_UNKNOWN)); + } + case PROVIDER_STATE_DISABLED: { + if (event != null || currentUserConfig != null) { + throw new IllegalArgumentException( + "Disabled state: event and currentUserConfig must be null" + + ", event=" + event + + ", currentUserConfig=" + currentUserConfig); + } + break; + } + case PROVIDER_STATE_ENABLED: { + if (currentUserConfig == null) { + throw new IllegalArgumentException( + "Enabled state: currentUserConfig must not be null"); + } + break; + } + case PROVIDER_STATE_PERM_FAILED: { + if (event != null || currentUserConfig != null) { + throw new IllegalArgumentException( + "Perf failed state: event and currentUserConfig must be null" + + ", event=" + event + + ", currentUserConfig=" + currentUserConfig); + } + break; + } + default: { + throw new IllegalArgumentException("Unknown newStateEnum=" + newStateEnum); + } + } + return new ProviderState(provider, newStateEnum, event, currentUserConfig, debugInfo); + } + + @Override + public String toString() { + return "State{" + + "stateEnum=" + prettyPrintStateEnum(stateEnum) + + ", event=" + event + + ", currentUserConfiguration=" + currentUserConfiguration + + ", mStateEntryTimeMillis=" + mStateEntryTimeMillis + + ", mDebugInfo=" + mDebugInfo + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProviderState state = (ProviderState) o; + return stateEnum == state.stateEnum + && Objects.equals(event, state.event) + && Objects.equals(currentUserConfiguration, state.currentUserConfiguration); + } + + @Override + public int hashCode() { + return Objects.hash(stateEnum, event, currentUserConfiguration); + } + + private static String prettyPrintStateEnum(@ProviderStateEnum int state) { + switch (state) { + case PROVIDER_STATE_DISABLED: + return "Disabled (" + PROVIDER_STATE_DISABLED + ")"; + case PROVIDER_STATE_ENABLED: + return "Enabled (" + PROVIDER_STATE_ENABLED + ")"; + case PROVIDER_STATE_PERM_FAILED: + return "Perm failure (" + PROVIDER_STATE_PERM_FAILED + ")"; + case PROVIDER_STATE_UNKNOWN: + default: + return "Unknown (" + state + ")"; + } + } + } + + @NonNull final ThreadingDomain mThreadingDomain; + @NonNull final Object mSharedLock; + @NonNull final String mProviderName; + + /** + * The current state (with history for debugging). + */ + @GuardedBy("mSharedLock") + final ReferenceWithHistory<ProviderState> mCurrentState = + new ReferenceWithHistory<>(10); + + // Non-null and effectively final after initialize() is called. + ProviderListener mProviderListener; + + /** Creates the instance. */ + LocationTimeZoneProvider(@NonNull ThreadingDomain threadingDomain, + @NonNull String providerName) { + mThreadingDomain = Objects.requireNonNull(threadingDomain); + mSharedLock = threadingDomain.getLockObject(); + mProviderName = Objects.requireNonNull(providerName); + } + + /** + * Called before the provider is first used. + */ + final void initialize(@NonNull ProviderListener providerListener) { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + if (mProviderListener != null) { + throw new IllegalStateException("initialize already called"); + } + mProviderListener = Objects.requireNonNull(providerListener); + ProviderState currentState = ProviderState.createStartingState(this); + ProviderState newState = currentState.newState( + PROVIDER_STATE_DISABLED, null, null, "initialize() called"); + setCurrentState(newState, false); + + onInitialize(); + } + } + + /** + * Implemented by subclasses to do work during {@link #initialize}. + */ + abstract void onInitialize(); + + /** + * Set the current state, for use by this class and subclasses only. If {@code #notifyChanges} + * is {@code true} and {@code newState} is not equal to the old state, then {@link + * ProviderListener#onProviderStateChange(ProviderState)} must be called on + * {@link #mProviderListener}. + */ + final void setCurrentState(@NonNull ProviderState newState, boolean notifyChanges) { + mThreadingDomain.assertCurrentThread(); + synchronized (mSharedLock) { + ProviderState oldState = mCurrentState.get(); + mCurrentState.set(newState); + onSetCurrentState(newState); + if (notifyChanges) { + if (!Objects.equals(newState, oldState)) { + mProviderListener.onProviderStateChange(newState); + } + } + } + } + + /** + * Overridden by subclasses to do work during {@link #setCurrentState}. + */ + @GuardedBy("mSharedLock") + void onSetCurrentState(ProviderState newState) { + // Default no-op. + } + + /** + * Returns the current state of the provider. This method must be called using the handler + * thread from the {@link ThreadingDomain}. + */ + @NonNull + final ProviderState getCurrentState() { + mThreadingDomain.assertCurrentThread(); + synchronized (mSharedLock) { + return mCurrentState.get(); + } + } + + /** + * Returns the name of the provider. This method must be called using the handler thread from + * the {@link ThreadingDomain}. + */ + final String getName() { + mThreadingDomain.assertCurrentThread(); + return mProviderName; + } + + /** + * Enables the provider. It is an error to call this method except when the {@link + * #getCurrentState()} is at {@link ProviderState#PROVIDER_STATE_DISABLED}. This method must be + * called using the handler thread from the {@link ThreadingDomain}. + */ + final void enable(@NonNull ConfigurationInternal currentUserConfiguration) { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + assertCurrentState(PROVIDER_STATE_DISABLED); + + ProviderState currentState = getCurrentState(); + ProviderState newState = currentState.newState( + PROVIDER_STATE_ENABLED, null, currentUserConfiguration, "enable() called"); + setCurrentState(newState, false); + onEnable(); + } + } + + /** + * Implemented by subclasses to do work during {@link #enable}. + */ + abstract void onEnable(); + + /** + * Disables the provider. It is an error* to call this method except when the {@link + * #getCurrentState()} is at {@link ProviderState#PROVIDER_STATE_ENABLED}. This method must be + * called using the handler thread from the {@link ThreadingDomain}. + */ + final void disable() { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + assertCurrentState(PROVIDER_STATE_ENABLED); + + ProviderState currentState = getCurrentState(); + ProviderState newState = + currentState.newState(PROVIDER_STATE_DISABLED, null, null, "disable() called"); + setCurrentState(newState, false); + + onDisable(); + } + } + + /** + * Implemented by subclasses to do work during {@link #disable}. + */ + abstract void onDisable(); + + /** For subclasses to invoke when a {@link LocationTimeZoneEvent} has been received. */ + final void handleLocationTimeZoneEvent( + @NonNull LocationTimeZoneEvent locationTimeZoneEvent) { + mThreadingDomain.assertCurrentThread(); + Objects.requireNonNull(locationTimeZoneEvent); + + synchronized (mSharedLock) { + debugLog("handleLocationTimeZoneEvent: mProviderName=" + mProviderName + + ", locationTimeZoneEvent=" + locationTimeZoneEvent); + + ProviderState currentState = getCurrentState(); + int eventType = locationTimeZoneEvent.getEventType(); + switch (currentState.stateEnum) { + case PROVIDER_STATE_PERM_FAILED: { + // After entering perm failed, there is nothing to do. The remote peer is + // supposed to stop sending events after it has reported perm failure. + logWarn("handleLocationTimeZoneEvent: Event=" + locationTimeZoneEvent + + " received for provider=" + this + " when in failed state"); + return; + } + case PROVIDER_STATE_DISABLED: { + switch (eventType) { + case EVENT_TYPE_PERMANENT_FAILURE: { + String msg = "handleLocationTimeZoneEvent:" + + " Failure event=" + locationTimeZoneEvent + + " received for disabled provider=" + this + + ", entering permanently failed state"; + logWarn(msg); + ProviderState newState = currentState.newState( + PROVIDER_STATE_PERM_FAILED, null, null, msg); + setCurrentState(newState, true); + return; + } + case EVENT_TYPE_SUCCESS: + case EVENT_TYPE_UNCERTAIN: { + // Any geolocation-related events received for a disabled provider are + // ignored: they should not happen. + logWarn("handleLocationTimeZoneEvent:" + + " event=" + locationTimeZoneEvent + + " received for disabled provider=" + this + + ", ignoring"); + + return; + } + default: { + throw new IllegalStateException( + "Unknown eventType=" + locationTimeZoneEvent); + } + } + } + case PROVIDER_STATE_ENABLED: { + switch (eventType) { + case EVENT_TYPE_PERMANENT_FAILURE: { + String msg = "handleLocationTimeZoneEvent:" + + " Failure event=" + locationTimeZoneEvent + + " received for provider=" + this + + ", entering permanently failed state"; + logWarn(msg); + ProviderState newState = currentState.newState( + PROVIDER_STATE_PERM_FAILED, null, null, msg); + setCurrentState(newState, true); + return; + } + case EVENT_TYPE_UNCERTAIN: + case EVENT_TYPE_SUCCESS: { + ProviderState newState = currentState.newState(PROVIDER_STATE_ENABLED, + locationTimeZoneEvent, currentState.currentUserConfiguration, + "handleLocationTimeZoneEvent() when enabled"); + setCurrentState(newState, true); + return; + } + default: { + throw new IllegalStateException( + "Unknown eventType=" + locationTimeZoneEvent); + } + } + } + default: { + throw new IllegalStateException("Unknown providerType=" + currentState); + } + } + } + } + + /** + * Implemented by subclasses. + */ + abstract void logWarn(String msg); + + private void assertCurrentState(@ProviderState.ProviderStateEnum int requiredState) { + ProviderState currentState = getCurrentState(); + if (currentState.stateEnum != requiredState) { + throw new IllegalStateException( + "Required stateEnum=" + requiredState + ", but was " + currentState); + } + } +} diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java new file mode 100644 index 000000000000..2f75c43594df --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; +import android.os.Handler; + +import com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState; +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.Dumpable; +import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion; + +import java.util.Objects; + +/** + * An base class for the component responsible handling events from {@link + * LocationTimeZoneProvider}s and synthesizing time zone ID suggestions for sending to the time zone + * detector. This interface primarily exists to extract testable detection logic, i.e. with + * a minimal number of threading considerations or dependencies on Android infrastructure. + * + * <p>The controller interacts with the following components: + * <ul> + * <li>The surrounding service, which calls {@link #initialize(Environment, Callback)} and + * {@link #onConfigChanged()}.</li> + * <li>The {@link Environment} through which obtains information it needs.</li> + * <li>The {@link Callback} through which it makes time zone suggestions.</li> + * <li>Any {@link LocationTimeZoneProvider} instances it owns, which communicate via the + * {@link LocationTimeZoneProvider.ProviderListener#onProviderStateChange(ProviderState)} + * method.</li> + * </ul> + * + * <p>All incoming calls except for {@link + * LocationTimeZoneProviderController#dump(android.util.IndentingPrintWriter, String[])} must be + * made on the {@link Handler} thread of the {@link ThreadingDomain} passed to {@link + * #LocationTimeZoneProviderController(ThreadingDomain)}. + * + * <p>Provider / controller integration notes: + * + * <p>Providers distinguish between "unknown unknowns" ("uncertain") and "known unknowns" + * ("certain"), i.e. a provider can be uncertain and not know what the time zone is, which is + * different from the certainty that there are no time zone IDs for the current location. A provider + * can be certain about there being no time zone IDs for a location for good reason, e.g. for + * disputed areas and oceans. Distinguishing uncertainty allows the controller to try other + * providers (or give up), where as certainty means it should not. + * + * <p>A provider can fail permanently. A permanent failure will disable the provider until next + * boot. + */ +abstract class LocationTimeZoneProviderController implements Dumpable { + + @NonNull protected final ThreadingDomain mThreadingDomain; + @NonNull protected final Object mSharedLock; + + LocationTimeZoneProviderController(@NonNull ThreadingDomain threadingDomain) { + mThreadingDomain = Objects.requireNonNull(threadingDomain); + mSharedLock = threadingDomain.getLockObject(); + } + + /** + * Called to initialize the controller during boot. Called once only. + * {@link LocationTimeZoneProvider#initialize} must be called by this method. + */ + abstract void initialize(@NonNull Environment environment, @NonNull Callback callback); + + /** + * Called when any settings or other device state that affect location-based time zone detection + * have changed. The receiver should call {@link + * Environment#getCurrentUserConfigurationInternal()} to get the current user's config. This + * call must be made on the {@link ThreadingDomain} handler thread. + */ + abstract void onConfigChanged(); + + /** + * Used by {@link LocationTimeZoneProviderController} to obtain information from the surrounding + * service. It can easily be faked for tests. + */ + abstract static class Environment { + + @NonNull protected final ThreadingDomain mThreadingDomain; + @NonNull protected final Object mSharedLock; + + Environment(@NonNull ThreadingDomain threadingDomain) { + mThreadingDomain = Objects.requireNonNull(threadingDomain); + mSharedLock = threadingDomain.getLockObject(); + } + + /** Returns the {@link ConfigurationInternal} for the current user of the device. */ + abstract ConfigurationInternal getCurrentUserConfigurationInternal(); + } + + /** + * Used by {@link LocationTimeZoneProviderController} to interact with the surrounding service. + * It can easily be faked for tests. + */ + abstract static class Callback { + + @NonNull protected final ThreadingDomain mThreadingDomain; + @NonNull protected final Object mSharedLock; + + Callback(@NonNull ThreadingDomain threadingDomain) { + mThreadingDomain = Objects.requireNonNull(threadingDomain); + mSharedLock = threadingDomain.getLockObject(); + } + + /** + * Suggests the latest time zone state for the device. + */ + abstract void suggest(@NonNull GeolocationTimeZoneSuggestion suggestion); + } +} diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java new file mode 100644 index 000000000000..3d889ae1856a --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.location.timezone.LocationTimeZoneEvent; +import android.os.Handler; +import android.util.IndentingPrintWriter; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.location.timezone.LocationTimeZoneProviderRequest; +import com.android.server.timezonedetector.Dumpable; + +import java.util.Objects; + +/** + * System server-side proxy for ILocationTimeZoneProvider implementations, i.e. this provides the + * system server object used to communicate with a remote LocationTimeZoneProvider over Binder, + * which could be running in a different process. As LocationTimeZoneProviders are bound / unbound + * this proxy will rebind to the "best" available remote process. + * + * <p>Threading guarantees provided / required by this interface: + * <ul> + * <li>All public methods defined by this class must be invoked using the {@link Handler} thread + * from the {@link ThreadingDomain} passed to the constructor, excluding + * {@link #dump(IndentingPrintWriter, String[])}</li> + * <li>Non-static public methods that make binder calls to remote processes (e.g. + * {@link #setRequest(LocationTimeZoneProviderRequest)}) are executed asynchronously and will + * return immediately.</li> + * <li>Callbacks received via binder are delivered via {@link Listener} are delivered on the + * {@link Handler} thread from the {@link ThreadingDomain} passed to the constructor. + * </ul> + * + * <p>This class exists to enable the introduction of test implementations of {@link + * LocationTimeZoneProviderProxy} that can be used when a device is in a test mode to inject test + * events / behavior that are otherwise difficult to simulate. + */ +abstract class LocationTimeZoneProviderProxy implements Dumpable { + + @NonNull protected final Context mContext; + @NonNull protected final ThreadingDomain mThreadingDomain; + @NonNull protected final Object mSharedLock; + + // Non-null and effectively final after setListener() is called. + @GuardedBy("mSharedLock") + @Nullable + protected Listener mListener; + + LocationTimeZoneProviderProxy( + @NonNull Context context, @NonNull ThreadingDomain threadingDomain) { + mContext = Objects.requireNonNull(context); + mThreadingDomain = Objects.requireNonNull(threadingDomain); + mSharedLock = threadingDomain.getLockObject(); + } + + /** + * Sets the listener. The listener can expect to receive all events after this point. + */ + void setListener(@NonNull Listener listener) { + Objects.requireNonNull(listener); + synchronized (mSharedLock) { + if (mListener != null) { + throw new IllegalStateException("listener already set"); + } + this.mListener = listener; + } + } + + /** + * Sets a new request for the provider. + */ + abstract void setRequest(@NonNull LocationTimeZoneProviderRequest request); + + /** + * Handles a {@link LocationTimeZoneEvent} from a remote process. + */ + final void handleLocationTimeZoneEvent( + @NonNull LocationTimeZoneEvent locationTimeZoneEvent) { + // These calls are invoked on a binder thread. Move to the mThreadingDomain thread as + // required by the guarantees for this class. + mThreadingDomain.post(() -> mListener.onReportLocationTimeZoneEvent(locationTimeZoneEvent)); + } + + /** + * Interface for listening to location time zone providers. See {@link + * LocationTimeZoneProviderProxy} for threading guarantees. + */ + interface Listener { + + /** + * Called when a provider receives a {@link LocationTimeZoneEvent}. + */ + void onReportLocationTimeZoneEvent(@NonNull LocationTimeZoneEvent locationTimeZoneEvent); + + /** + * Called when a provider is (re)bound. + */ + void onProviderBound(); + + /** Called when a provider is unbound. */ + void onProviderUnbound(); + } +} diff --git a/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java new file mode 100644 index 000000000000..79e2b975089d --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.util.IndentingPrintWriter; +import android.util.Slog; + +/** + * A {@link LocationTimeZoneProvider} that provides minimal responses needed for the {@link + * LocationTimeZoneProviderController} to operate correctly when there is no "real" provider + * configured. This can be used during development / testing, or in a production build when the + * platform supports more providers than are needed for an Android deployment. + * + * <p>For example, if the {@link LocationTimeZoneProviderController} supports a primary + * and a secondary {@link LocationTimeZoneProvider}, but only a primary is configured, the secondary + * config will be left null and the {@link LocationTimeZoneProvider} implementation will be + * defaulted to a {@link NullLocationTimeZoneProvider}. The {@link NullLocationTimeZoneProvider} + * enters a {@link ProviderState#PROVIDER_STATE_PERM_FAILED} state immediately after being enabled + * for the first time and sends the appropriate event, which ensures the {@link + * LocationTimeZoneProviderController} won't expect any further {@link + * android.location.timezone.LocationTimeZoneEvent}s to come from it, and won't attempt to use it + * again. + */ +class NullLocationTimeZoneProvider extends LocationTimeZoneProvider { + + private static final String TAG = "NullLocationTimeZoneProvider"; + + /** Creates the instance. */ + NullLocationTimeZoneProvider(@NonNull ThreadingDomain threadingDomain, + @NonNull String providerName) { + super(threadingDomain, providerName); + } + + @Override + void onInitialize() { + // No-op + } + + @Override + void onEnable() { + // Report a failure (asynchronously using the mThreadingDomain thread to avoid recursion). + mThreadingDomain.post(()-> { + // Enter the perm-failed state. + ProviderState currentState = mCurrentState.get(); + ProviderState failedState = currentState.newState( + PROVIDER_STATE_PERM_FAILED, null, null, "Stubbed provider"); + setCurrentState(failedState, true); + }); + } + + @Override + void onDisable() { + // Ignored - NullLocationTimeZoneProvider is always permanently failed. + } + + @Override + void logWarn(String msg) { + Slog.w(TAG, msg); + } + + @Override + public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) { + synchronized (mSharedLock) { + ipw.println("{Stubbed LocationTimeZoneProvider}"); + ipw.println("mProviderName=" + mProviderName); + ipw.println("mCurrentState=" + mCurrentState); + } + } + + @Override + public String toString() { + synchronized (mSharedLock) { + return "NullLocationTimeZoneProvider{" + + "mProviderName='" + mProviderName + '\'' + + "mCurrentState='" + mCurrentState + '\'' + + '}'; + } + } +} diff --git a/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java b/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java new file mode 100644 index 000000000000..ef2e349fceaa --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN; + +import static com.android.server.location.timezone.LocationTimeZoneManagerService.PRIMARY_PROVIDER_NAME; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.ActivityManager; +import android.location.timezone.LocationTimeZoneEvent; +import android.os.ShellCommand; +import android.os.SystemClock; +import android.os.UserHandle; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * An event used for simulating real binder proxy behavior using a {@link + * SimulatedLocationTimeZoneProviderProxy}. + */ +final class SimulatedBinderProviderEvent { + + private static final List<String> VALID_PROVIDER_NAMES = Arrays.asList(PRIMARY_PROVIDER_NAME); + + static final int INJECTED_EVENT_TYPE_ON_BIND = 1; + static final int INJECTED_EVENT_TYPE_ON_UNBIND = 2; + static final int INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT = 3; + + + @NonNull private final String mProviderName; + private final int mEventType; + @Nullable private final LocationTimeZoneEvent mLocationTimeZoneEvent; + + private SimulatedBinderProviderEvent(@NonNull String providerName, int eventType, + @Nullable LocationTimeZoneEvent locationTimeZoneEvent) { + this.mProviderName = Objects.requireNonNull(providerName); + this.mEventType = eventType; + this.mLocationTimeZoneEvent = locationTimeZoneEvent; + } + + @NonNull + String getProviderName() { + return mProviderName; + } + + @Nullable + LocationTimeZoneEvent getLocationTimeZoneEvent() { + return mLocationTimeZoneEvent; + } + + int getEventType() { + return mEventType; + } + + /** Prints the command line options that {@link #createFromArgs(ShellCommand)} understands. */ + static void printCommandLineOpts(PrintWriter pw) { + pw.println("Simulated provider binder event:"); + pw.println(); + pw.println("<provider name> [onBind|onUnbind|locationTimeZoneEvent" + + " <location time zone event args>]"); + pw.println(); + pw.println("<provider name> = " + VALID_PROVIDER_NAMES); + pw.println("<location time zone event args> =" + + " [PERMANENT_FAILURE|UNCERTAIN|SUCCESS <time zone ids>*]"); + } + + /** + * Constructs a {@link SimulatedBinderProviderEvent} from the arguments of {@code shellCommand}. + */ + static SimulatedBinderProviderEvent createFromArgs(ShellCommand shellCommand) { + String providerName = shellCommand.getNextArgRequired(); + if (!VALID_PROVIDER_NAMES.contains(providerName)) { + throw new IllegalArgumentException("Unknown provider name=" + providerName); + } + String injectedEvent = shellCommand.getNextArgRequired(); + switch (injectedEvent) { + case "onBind": { + return new SimulatedBinderProviderEvent( + providerName, INJECTED_EVENT_TYPE_ON_BIND, null); + } + case "onUnbind": { + return new SimulatedBinderProviderEvent( + providerName, INJECTED_EVENT_TYPE_ON_UNBIND, null); + } + case "locationTimeZoneEvent": { + LocationTimeZoneEvent event = parseLocationTimeZoneEventArgs(shellCommand); + return new SimulatedBinderProviderEvent(providerName, + INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT, event); + } + default: { + throw new IllegalArgumentException("Unknown simulated event type=" + injectedEvent); + } + } + } + + private static LocationTimeZoneEvent parseLocationTimeZoneEventArgs(ShellCommand shellCommand) { + LocationTimeZoneEvent.Builder eventBuilder = new LocationTimeZoneEvent.Builder() + .setElapsedRealtimeNanos(SystemClock.elapsedRealtime()) + .setUserHandle(UserHandle.of(ActivityManager.getCurrentUser())); + + String eventTypeString = shellCommand.getNextArgRequired(); + switch (eventTypeString.toUpperCase()) { + case "PERMANENT_FAILURE": { + eventBuilder.setEventType(EVENT_TYPE_PERMANENT_FAILURE); + break; + } + case "UNCERTAIN": { + eventBuilder.setEventType(EVENT_TYPE_UNCERTAIN); + break; + } + case "SUCCESS": { + eventBuilder.setEventType(EVENT_TYPE_SUCCESS) + .setTimeZoneIds(parseTimeZoneArgs(shellCommand)); + break; + } + default: { + throw new IllegalArgumentException("Error: Unknown eventType: " + eventTypeString); + } + } + return eventBuilder.build(); + } + + private static List<String> parseTimeZoneArgs(ShellCommand shellCommand) { + List<String> timeZoneIds = new ArrayList<>(); + String timeZoneId; + while ((timeZoneId = shellCommand.getNextArg()) != null) { + timeZoneIds.add(timeZoneId); + } + return timeZoneIds; + } + + @Override + public String toString() { + return "SimulatedBinderProviderEvent{" + + "mProviderName=" + mProviderName + + ", mEventType=" + mEventType + + ", mLocationTimeZoneEvent=" + mLocationTimeZoneEvent + + '}'; + } +} diff --git a/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java b/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java new file mode 100644 index 000000000000..462bcab80c1b --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT; +import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_ON_BIND; +import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_ON_UNBIND; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.util.IndentingPrintWriter; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.location.timezone.LocationTimeZoneProviderRequest; +import com.android.server.timezonedetector.ReferenceWithHistory; + +import java.util.Objects; + +/** + * A replacement for a real binder proxy for use during integration testing + * that can be used to inject simulated {@link LocationTimeZoneProviderProxy} behavior. + */ +class SimulatedLocationTimeZoneProviderProxy extends LocationTimeZoneProviderProxy { + + @GuardedBy("mProxyLock") + @NonNull private LocationTimeZoneProviderRequest mRequest; + + @NonNull private ReferenceWithHistory<String> mLastEvent = new ReferenceWithHistory<>(50); + + SimulatedLocationTimeZoneProviderProxy( + @NonNull Context context, @NonNull ThreadingDomain threadingDomain) { + super(context, threadingDomain); + mRequest = LocationTimeZoneProviderRequest.EMPTY_REQUEST; + } + + void simulate(@NonNull SimulatedBinderProviderEvent event) { + switch (event.getEventType()) { + case INJECTED_EVENT_TYPE_ON_BIND: { + mLastEvent.set("Simulating onProviderBound(), event=" + event); + mThreadingDomain.post(this::onBindOnHandlerThread); + break; + } + case INJECTED_EVENT_TYPE_ON_UNBIND: { + mLastEvent.set("Simulating onProviderUnbound(), event=" + event); + mThreadingDomain.post(this::onUnbindOnHandlerThread); + break; + } + case INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT: { + if (!mRequest.getReportLocationTimeZone()) { + mLastEvent.set("Test event=" + event + " is testing an invalid case:" + + " reporting is off. mRequest=" + mRequest); + } + mLastEvent.set("Simulating LocationTimeZoneEvent, event=" + event); + handleLocationTimeZoneEvent(event.getLocationTimeZoneEvent()); + break; + } + default: { + mLastEvent.set("Unknown simulated event type. event=" + event); + throw new IllegalArgumentException("Unknown simulated event type. event=" + event); + } + } + } + + private void onBindOnHandlerThread() { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + mListener.onProviderBound(); + } + } + + private void onUnbindOnHandlerThread() { + mThreadingDomain.assertCurrentThread(); + + synchronized (mSharedLock) { + mListener.onProviderUnbound(); + } + } + + @Override + final void setRequest(@NonNull LocationTimeZoneProviderRequest request) { + mThreadingDomain.assertCurrentThread(); + + Objects.requireNonNull(request); + synchronized (mSharedLock) { + mLastEvent.set("Request received: " + request); + mRequest = request; + } + } + + @Override + public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) { + synchronized (mSharedLock) { + ipw.println("mRequest=" + mRequest); + ipw.println("mLastEvent=" + mLastEvent); + + ipw.increaseIndent(); + ipw.println("Last event history:"); + mLastEvent.dump(ipw); + ipw.decreaseIndent(); + } + } +} diff --git a/services/core/java/com/android/server/location/timezone/ThreadingDomain.java b/services/core/java/com/android/server/location/timezone/ThreadingDomain.java new file mode 100644 index 000000000000..9b9c82358974 --- /dev/null +++ b/services/core/java/com/android/server/location/timezone/ThreadingDomain.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.location.timezone; + +import android.annotation.NonNull; + +import com.android.internal.util.Preconditions; + +/** + * A class that can be used to enforce / indicate a set of components that need to share threading + * behavior such as a shared lock object and a common thread, with async execution support. + * + * <p>It is <em>not</em> essential that the object returned by {@link #getLockObject()} is only used + * when executing on the domain's thread, but users should be careful to avoid deadlocks when + * multiple locks / threads are in use. Generally sticking to a single thread / lock is safest. + */ +abstract class ThreadingDomain { + + @NonNull private final Object mLockObject; + + ThreadingDomain() { + mLockObject = new Object(); + } + + /** + * Returns the common lock object for this threading domain that can be used for synchronized () + * blocks. The lock is unique to this threading domain. + */ + @NonNull + Object getLockObject() { + return mLockObject; + } + + /** + * Returns the Thread associated with this threading domain. + */ + @NonNull + abstract Thread getThread(); + + /** + * Asserts the currently executing thread is the one associated with this threading domain. + * Generally useful for documenting expectations in the code. By asserting a single thread is + * being used within a set of components, a lot of races can be avoided. + */ + void assertCurrentThread() { + Preconditions.checkArgument(Thread.currentThread() == getThread()); + } + + /** + * Execute the supplied runnable on the threading domain's thread. + */ + abstract void post(@NonNull Runnable runnable); + + /** + * Execute the supplied runnable on the threading domain's thread with a delay. + */ + abstract void postDelayed(@NonNull Runnable runnable, long delayMillis); + + abstract void postDelayed(Runnable r, Object token, long delayMillis); + + abstract void removeQueuedRunnables(Object token); + + /** + * Creates a new {@link SingleRunnableQueue} that can be used to ensure that (at most) a + * single runnable for a given purpose is ever queued. Create new ones for different purposes. + */ + SingleRunnableQueue createSingleRunnableQueue() { + return new SingleRunnableQueue(); + } + + /** + * A class that allows up to one {@link Runnable} to be queued on the handler, i.e. calling any + * of the methods will cancel the execution of any previously queued / delayed runnable. All + * methods must be called from the {@link ThreadingDomain}'s thread. + */ + final class SingleRunnableQueue { + + /** + * Runs the supplied {@link Runnable} synchronously on the threading domain's thread, + * cancelling any queued but not-yet-executed {@link Runnable} previously added by this. + * This method must be called from the threading domain's thread. + */ + void runSynchronously(Runnable r) { + cancel(); + r.run(); + } + + /** + * Posts the supplied {@link Runnable} asynchronously and delayed on the threading domain + * handler thread, cancelling any queued but not-yet-executed {@link Runnable} previously + * added by this. This method must be called from the threading domain's thread. + */ + void runDelayed(Runnable r, long delayMillis) { + cancel(); + ThreadingDomain.this.postDelayed(r, this, delayMillis); + } + + /** + * Cancels any queued but not-yet-executed {@link Runnable} previously added by this. + */ + public void cancel() { + assertCurrentThread(); + removeQueuedRunnables(this); + } + } +} diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java index 7501d9fe6a7c..73322a6987df 100644 --- a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java @@ -57,8 +57,12 @@ public final class TimeZoneDetectorService extends ITimeZoneDetectorService.Stub private static final String TAG = "TimeZoneDetectorService"; - /** A compile time switch for enabling / disabling geolocation-based time zone detection. */ - private static final boolean GEOLOCATION_TIME_ZONE_DETECTION_ENABLED = false; + /** + * A compile time constant "feature switch" for enabling / disabling location-based time zone + * detection on Android. If this is {@code false}, there should be few / little changes in + * behavior with previous releases and little overhead associated with geolocation components. + */ + public static final boolean GEOLOCATION_TIME_ZONE_DETECTION_ENABLED = false; /** * Handles the service lifecycle for {@link TimeZoneDetectorService} and diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java index ddd23778884a..eca9f1556d43 100644 --- a/services/java/com/android/server/SystemServer.java +++ b/services/java/com/android/server/SystemServer.java @@ -293,6 +293,8 @@ public final class SystemServer { "com.android.server.timedetector.TimeDetectorService$Lifecycle"; private static final String TIME_ZONE_DETECTOR_SERVICE_CLASS = "com.android.server.timezonedetector.TimeZoneDetectorService$Lifecycle"; + private static final String LOCATION_TIME_ZONE_MANAGER_SERVICE_CLASS = + "com.android.server.location.timezone.LocationTimeZoneManagerService$Lifecycle"; private static final String ACCESSIBILITY_MANAGER_SERVICE_CLASS = "com.android.server.accessibility.AccessibilityManagerService$Lifecycle"; private static final String ADB_SERVICE_CLASS = @@ -1621,7 +1623,7 @@ public final class SystemServer { try { mSystemServiceManager.startService(TIME_DETECTOR_SERVICE_CLASS); } catch (Throwable e) { - reportWtf("starting StartTimeDetectorService service", e); + reportWtf("starting TimeDetectorService service", e); } t.traceEnd(); @@ -1629,7 +1631,15 @@ public final class SystemServer { try { mSystemServiceManager.startService(TIME_ZONE_DETECTOR_SERVICE_CLASS); } catch (Throwable e) { - reportWtf("starting StartTimeZoneDetectorService service", e); + reportWtf("starting TimeZoneDetectorService service", e); + } + t.traceEnd(); + + t.traceBegin("StartLocationTimeZoneManagerService"); + try { + mSystemServiceManager.startService(LOCATION_TIME_ZONE_MANAGER_SERVICE_CLASS); + } catch (Throwable e) { + reportWtf("starting LocationTimeZoneManagerService service", e); } t.traceEnd(); diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java new file mode 100644 index 000000000000..dbaad6633e98 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java @@ -0,0 +1,528 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS; +import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN; + +import static com.android.server.location.timezone.ControllerImpl.UNCERTAINTY_DELAY; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED; +import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_DISABLED; +import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_ENABLED; +import static com.android.server.location.timezone.TestSupport.USER1_ID; +import static com.android.server.location.timezone.TestSupport.USER2_CONFIG_GEO_DETECTION_ENABLED; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import static java.util.Arrays.asList; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.UserIdInt; +import android.location.timezone.LocationTimeZoneEvent; +import android.os.UserHandle; +import android.platform.test.annotations.Presubmit; +import android.util.IndentingPrintWriter; + +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion; +import com.android.server.timezonedetector.TestState; + +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Tests for {@link ControllerImpl}. */ +@Presubmit +public class ControllerImplTest { + + private static final long ARBITRARY_TIME = 12345L; + + private static final LocationTimeZoneEvent USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1 = + createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_SUCCESS, asList("Europe/London")); + private static final LocationTimeZoneEvent USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2 = + createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_SUCCESS, asList("Europe/Paris")); + private static final LocationTimeZoneEvent USER1_UNCERTAIN_LOCATION_TIME_ZONE_EVENT = + createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_UNCERTAIN, null); + + private TestThreadingDomain mTestThreadingDomain; + private TestCallback mTestCallback; + private TestLocationTimeZoneProvider mTestLocationTimeZoneProvider; + + @Before + public void setUp() { + // For simplicity, the TestThreadingDomain uses the test's main thread. To execute posted + // runnables, the test must call methods on mTestThreadingDomain otherwise those runnables + // will never get a chance to execute. + mTestThreadingDomain = new TestThreadingDomain(); + mTestCallback = new TestCallback(mTestThreadingDomain); + mTestLocationTimeZoneProvider = + new TestLocationTimeZoneProvider(mTestThreadingDomain, "primary"); + } + + @Test + public void initialState_enabled() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertInitialized(); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertNextQueueItemIsDelayed(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + } + + @Test + public void initialState_disabled() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_DISABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertInitialized(); + + mTestLocationTimeZoneProvider.assertIsDisabled(); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertNoSuggestionMade(); + } + + @Test + public void enabled_uncertaintySuggestionSentIfNoEventReceived() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestCallback.assertNoSuggestionMade(); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + + // Simulate time passing with no event being received. + mTestThreadingDomain.executeNext(); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestCallback.assertUncertainSuggestionMadeAndCommit(); + mTestThreadingDomain.assertQueueEmpty(); + } + + @Test + public void enabled_uncertaintySuggestionCancelledIfEventReceived() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Simulate a location event being received by the provider. This should cause a suggestion + // to be made, and the timeout to be cleared. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds()); + } + + @Test + public void enabled_repeatedCertainty() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Simulate a location event being received by the provider. This should cause a suggestion + // to be made, and the timeout to be cleared. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds()); + + // A second, identical event should not cause another suggestion. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertNoSuggestionMade(); + + // And a third, different event should cause another suggestion. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2.getTimeZoneIds()); + } + + @Test + public void enabled_briefUncertaintyTriggersNoSuggestion() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Simulate a location event being received by the provider. This should cause a suggestion + // to be made, and the timeout to be cleared. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds()); + + // Uncertainty should cause + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_UNCERTAIN_LOCATION_TIME_ZONE_EVENT); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // And a third event should cause yet another suggestion. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2.getTimeZoneIds()); + } + + @Test + public void configChanges_enableAndDisable() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_DISABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsDisabled(); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertNoSuggestionMade(); + + // Now signal a config change so that geo detection is enabled. + testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_ENABLED); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertNextQueueItemIsDelayed(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Now signal a config change so that geo detection is disabled. + testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_DISABLED); + + mTestLocationTimeZoneProvider.assertIsDisabled(); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertNoSuggestionMade(); + } + + @Test + public void configChanges_disableWithPreviousSuggestion() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Simulate a location event being received by the provider. This should cause a suggestion + // to be made, and the timeout to be cleared. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds()); + + // Simulate the user disabling the provider. + testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_DISABLED); + + // Because there had been a previous suggestion, the controller should withdraw it + // immediately to let the downstream components know that the provider can no longer be sure + // of the time zone. + mTestLocationTimeZoneProvider.assertIsDisabled(); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit(null); + } + + @Test + public void configChanges_userSwitch_enabledToEnabled() { + ControllerImpl controllerImpl = + new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider); + TestEnvironment testEnvironment = new TestEnvironment( + mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED); + controllerImpl.initialize(testEnvironment, mTestCallback); + + // There should be a runnable scheduled to suggest uncertainty if no event is received. + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Have the provider suggest a time zone. + mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1); + + // Receiving a "success" provider event should cause a suggestion to be made synchronously, + // and also clear the scheduled uncertainty suggestion. + mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertSuggestionMadeAndCommit( + USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds()); + + // Simulate the user change (but geo detection still enabled). + testEnvironment.simulateConfigChange(USER2_CONFIG_GEO_DETECTION_ENABLED); + + // We expect the provider to end up in PROVIDER_STATE_ENABLED, but it should have been + // disabled when the user changed. + // The controller should schedule a runnable to make a suggestion if the provider doesn't + // send a success event. + int[] expectedStateTransitions = { PROVIDER_STATE_DISABLED, PROVIDER_STATE_ENABLED }; + mTestLocationTimeZoneProvider.assertStateChangesAndCommit(expectedStateTransitions); + mTestLocationTimeZoneProvider.assertConfig(USER2_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY); + mTestCallback.assertNoSuggestionMade(); + + // Simulate no event being received, and time passing. + mTestThreadingDomain.executeNext(); + + mTestLocationTimeZoneProvider.assertIsEnabled(USER2_CONFIG_GEO_DETECTION_ENABLED); + mTestThreadingDomain.assertQueueEmpty(); + mTestCallback.assertUncertainSuggestionMadeAndCommit(); + } + + private static LocationTimeZoneEvent createLocationTimeZoneEvent(@UserIdInt int userId, + int eventType, @Nullable List<String> timeZoneIds) { + LocationTimeZoneEvent.Builder builder = new LocationTimeZoneEvent.Builder() + .setElapsedRealtimeNanos(ARBITRARY_TIME) + .setUserHandle(UserHandle.of(userId)) + .setEventType(eventType); + if (timeZoneIds != null) { + builder.setTimeZoneIds(timeZoneIds); + } + return builder.build(); + } + + private static class TestEnvironment extends LocationTimeZoneProviderController.Environment { + + private final LocationTimeZoneProviderController mController; + private ConfigurationInternal mConfigurationInternal; + + TestEnvironment(ThreadingDomain threadingDomain, + LocationTimeZoneProviderController controller, + ConfigurationInternal configurationInternal) { + super(threadingDomain); + mController = Objects.requireNonNull(controller); + mConfigurationInternal = Objects.requireNonNull(configurationInternal); + } + + @Override + ConfigurationInternal getCurrentUserConfigurationInternal() { + return mConfigurationInternal; + } + + void simulateConfigChange(ConfigurationInternal newConfig) { + ConfigurationInternal oldConfig = mConfigurationInternal; + mConfigurationInternal = Objects.requireNonNull(newConfig); + if (Objects.equals(oldConfig, newConfig)) { + fail("Bad test? No config change when one was expected"); + } + mController.onConfigChanged(); + } + } + + private static class TestCallback extends LocationTimeZoneProviderController.Callback { + + private TestState<GeolocationTimeZoneSuggestion> mLatestSuggestion = new TestState<>(); + + TestCallback(ThreadingDomain threadingDomain) { + super(threadingDomain); + } + + @Override + void suggest(GeolocationTimeZoneSuggestion suggestion) { + mLatestSuggestion.set(suggestion); + } + + void assertSuggestionMadeAndCommit(@Nullable List<String> expectedZoneIds) { + mLatestSuggestion.assertHasBeenSet(); + assertEquals(expectedZoneIds, mLatestSuggestion.getLatest().getZoneIds()); + mLatestSuggestion.commitLatest(); + } + + void assertNoSuggestionMade() { + mLatestSuggestion.assertHasNotBeenSet(); + } + + void assertUncertainSuggestionMadeAndCommit() { + // An "uncertain" suggestion has null time zone IDs. + assertSuggestionMadeAndCommit(null); + } + } + + private static class TestLocationTimeZoneProvider extends LocationTimeZoneProvider { + + /** Used to track historic provider states for tests. */ + private final TestState<ProviderState> mTestProviderState = new TestState<>(); + private boolean mInitialized; + + /** + * Creates the instance. + */ + TestLocationTimeZoneProvider(ThreadingDomain threadingDomain, String providerName) { + super(threadingDomain, providerName); + } + + @Override + void onInitialize() { + mInitialized = true; + } + + @Override + void onSetCurrentState(ProviderState newState) { + mTestProviderState.set(newState); + } + + @Override + void onEnable() { + // Nothing needed for tests. + } + + @Override + void onDisable() { + // Nothing needed for tests. + } + + @Override + void logWarn(String msg) { + System.out.println(msg); + } + + @Override + public void dump(IndentingPrintWriter pw, String[] args) { + // Nothing needed for tests. + } + + /** Asserts that {@link #initialize(ProviderListener)} has been called. */ + void assertInitialized() { + assertTrue(mInitialized); + } + + void assertIsDisabled() { + // Disabled providers don't hold config. + assertConfig(null); + assertIsEnabledAndCommit(false); + } + + /** + * Asserts the provider's config matches the expected, and the current state is set + * accordinly. Commits the latest changes to the state. + */ + void assertIsEnabled(@NonNull ConfigurationInternal expectedConfig) { + assertConfig(expectedConfig); + + boolean expectIsEnabled = expectedConfig.getAutoDetectionEnabledBehavior(); + assertIsEnabledAndCommit(expectIsEnabled); + } + + private void assertIsEnabledAndCommit(boolean enabled) { + ProviderState currentState = mCurrentState.get(); + if (enabled) { + assertEquals(PROVIDER_STATE_ENABLED, currentState.stateEnum); + } else { + assertEquals(PROVIDER_STATE_DISABLED, currentState.stateEnum); + } + mTestProviderState.commitLatest(); + } + + void assertConfig(@NonNull ConfigurationInternal expectedConfig) { + ProviderState currentState = mCurrentState.get(); + assertEquals(expectedConfig, currentState.currentUserConfiguration); + } + + void simulateLocationTimeZoneEvent(@NonNull LocationTimeZoneEvent event) { + handleLocationTimeZoneEvent(event); + } + + /** + * Asserts the most recent state changes. The ordering is such that the last element in the + * provided array is expected to be the current state. + */ + void assertStateChangesAndCommit(int... expectedProviderStates) { + if (expectedProviderStates.length == 0) { + mTestProviderState.assertHasNotBeenSet(); + } else { + mTestProviderState.assertChangeCount(expectedProviderStates.length); + + List<ProviderState> previousProviderStates = new ArrayList<>(); + for (int i = 0; i < expectedProviderStates.length; i++) { + previousProviderStates.add(mTestProviderState.getPrevious(i)); + } + // The loop above will produce a list with the most recent state in element 0. So, + // reverse the list as the arguments to this method are expected to be in order + // oldest...latest. + Collections.reverse(previousProviderStates); + + boolean allMatch = true; + for (int i = 0; i < expectedProviderStates.length; i++) { + allMatch = allMatch && expectedProviderStates[i] + == previousProviderStates.get(i).stateEnum; + } + if (!allMatch) { + fail("Provider state enums expected=" + Arrays.toString(expectedProviderStates) + + " but states were" + + " actually=" + previousProviderStates); + } + } + mTestProviderState.commitLatest(); + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java new file mode 100644 index 000000000000..cbaf0f391375 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.os.Handler; +import android.os.HandlerThread; +import android.platform.test.annotations.Presubmit; + +import com.android.server.location.timezone.ThreadingDomain.SingleRunnableQueue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Tests for {@link HandlerThreadingDomain}. */ +@Presubmit +public class HandlerThreadingDomainTest { + + private HandlerThread mHandlerThread; + private Handler mTestHandler; + + @Before + public void setUp() { + mHandlerThread = new HandlerThread("HandlerThreadingDomainTest"); + mHandlerThread.start(); + mTestHandler = new Handler(mHandlerThread.getLooper()); + } + + @After + public void tearDown() throws Exception { + mHandlerThread.quit(); + mHandlerThread.join(); + } + + @Test + public void getLockObject() { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + assertSame("LockObject must be consistent", domain.getLockObject(), domain.getLockObject()); + } + + @Test + public void assertCurrentThread() throws Exception { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + + // Expect an exception (current thread != handler thread) + try { + domain.assertCurrentThread(); + fail("Expected exception"); + } catch (RuntimeException expected) { + // Expected + } + + // Expect no exception (current thread == handler thread) + AtomicBoolean exceptionThrown = new AtomicBoolean(true); + LatchedRunnable testCode = new LatchedRunnable(() -> { + domain.assertCurrentThread(); + exceptionThrown.set(false); + }); + mTestHandler.post(testCode); + testCode.assertCompletesWithin(60, TimeUnit.SECONDS); + assertFalse(exceptionThrown.get()); + } + + @Test + public void post() throws Exception { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + AtomicBoolean ranOnExpectedThread = new AtomicBoolean(false); + LatchedRunnable testLogic = new LatchedRunnable(() -> { + ranOnExpectedThread.set(Thread.currentThread() == mTestHandler.getLooper().getThread()); + }); + domain.post(testLogic); + testLogic.assertCompletesWithin(60, TimeUnit.SECONDS); + assertTrue(ranOnExpectedThread.get()); + } + + @Test + public void postDelayed() throws Exception { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + + long beforeExecutionNanos = System.nanoTime(); + AtomicBoolean ranOnExpectedThread = new AtomicBoolean(false); + LatchedRunnable testLogic = new LatchedRunnable(() -> { + ranOnExpectedThread.set(Thread.currentThread() == mTestHandler.getLooper().getThread()); + }); + domain.postDelayed(testLogic, 5000); + + testLogic.assertCompletesWithin(60, TimeUnit.SECONDS); + assertTrue(ranOnExpectedThread.get()); + + long afterExecutionNanos = System.nanoTime(); + assertTrue(afterExecutionNanos - beforeExecutionNanos >= TimeUnit.SECONDS.toNanos(5)); + } + + @Test + public void singleRunnableHandler_runSynchronously() throws Exception { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + SingleRunnableQueue singleRunnableQueue = domain.createSingleRunnableQueue(); + + AtomicBoolean testPassed = new AtomicBoolean(false); + // Calls to SingleRunnableQueue must be made on the handler thread it is associated with, + // so this uses runWithScissors() to block until the lambda has completed. + mTestHandler.runWithScissors(() -> { + Thread testThread = Thread.currentThread(); + CountDownLatch latch = new CountDownLatch(1); + singleRunnableQueue.runSynchronously(() -> { + assertSame(Thread.currentThread(), testThread); + latch.countDown(); + }); + assertTrue(awaitWithRuntimeException(latch, 60, TimeUnit.SECONDS)); + testPassed.set(true); + }, TimeUnit.SECONDS.toMillis(60)); + assertTrue(testPassed.get()); + } + + @Test + public void singleRunnableHandler_runDelayed() throws Exception { + ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler); + SingleRunnableQueue singleRunnableQueue = domain.createSingleRunnableQueue(); + + long beforeExecutionNanos = System.nanoTime(); + + Runnable noOpRunnable = () -> { + // Deliberately do nothing + }; + LatchedRunnable firstRunnable = new LatchedRunnable(noOpRunnable); + LatchedRunnable secondRunnable = new LatchedRunnable(noOpRunnable); + + // Calls to SingleRunnableQueue must be made on the handler thread it is associated with, + // so this uses runWithScissors() to block until the runDelayedTestRunnable has completed. + Runnable runDelayedTestRunnable = () -> { + singleRunnableQueue.runDelayed(firstRunnable, TimeUnit.SECONDS.toMillis(10)); + + // The second runnable posted must clear the first. + singleRunnableQueue.runDelayed(secondRunnable, TimeUnit.SECONDS.toMillis(10)); + }; + mTestHandler.runWithScissors(runDelayedTestRunnable, TimeUnit.SECONDS.toMillis(60)); + + // Now wait for the second runnable to complete + secondRunnable.assertCompletesWithin(60, TimeUnit.SECONDS); + assertFalse(firstRunnable.isComplete()); + + long afterExecutionNanos = System.nanoTime(); + assertTrue(afterExecutionNanos - beforeExecutionNanos >= TimeUnit.SECONDS.toNanos(10)); + } + + private static boolean awaitWithRuntimeException( + CountDownLatch latch, long timeout, TimeUnit timeUnit) { + try { + return latch.await(timeout, timeUnit); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private static class LatchedRunnable implements Runnable { + + private final CountDownLatch mLatch = new CountDownLatch(1); + private final Runnable mRunnable; + + LatchedRunnable(Runnable mRunnable) { + this.mRunnable = Objects.requireNonNull(mRunnable); + } + + @Override + public void run() { + try { + mRunnable.run(); + } finally { + mLatch.countDown(); + } + } + + boolean isComplete() { + return mLatch.getCount() == 0; + } + + boolean waitForCompletion(long timeout, TimeUnit unit) { + return awaitWithRuntimeException(mLatch, timeout, unit); + } + + void assertCompletesWithin(long timeout, TimeUnit unit) { + assertTrue("Runnable did not execute in time", waitForCompletion(timeout, unit)); + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java new file mode 100644 index 000000000000..7c882fc1f154 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED; +import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED; +import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_ENABLED; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import android.platform.test.annotations.Presubmit; +import android.util.IndentingPrintWriter; + +import com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState; +import com.android.server.timezonedetector.ConfigurationInternal; +import com.android.server.timezonedetector.TestState; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link NullLocationTimeZoneProvider} and, indirectly, the class it extends + * {@link LocationTimeZoneProvider}. + */ +@Presubmit +public class NullLocationTimeZoneProviderTest { + + private TestThreadingDomain mTestThreadingDomain; + + private TestController mTestController; + + @Before + public void setUp() { + mTestThreadingDomain = new TestThreadingDomain(); + mTestController = new TestController(mTestThreadingDomain); + } + + @Test + public void initialization() { + String providerName = "primary"; + NullLocationTimeZoneProvider provider = + new NullLocationTimeZoneProvider(mTestThreadingDomain, providerName); + provider.initialize(providerState -> mTestController.onProviderStateChange(providerState)); + + ProviderState currentState = provider.getCurrentState(); + assertEquals(PROVIDER_STATE_DISABLED, currentState.stateEnum); + assertNull(currentState.currentUserConfiguration); + assertSame(provider, currentState.provider); + mTestThreadingDomain.assertQueueEmpty(); + } + + @Test + public void enableSchedulesPermFailure() { + String providerName = "primary"; + NullLocationTimeZoneProvider provider = + new NullLocationTimeZoneProvider(mTestThreadingDomain, providerName); + provider.initialize(providerState -> mTestController.onProviderStateChange(providerState)); + + ConfigurationInternal config = USER1_CONFIG_GEO_DETECTION_ENABLED; + provider.enable(config); + + // The StubbedProvider should enters enabled state, but immediately schedule a runnable to + // switch to perm failure. + ProviderState currentState = provider.getCurrentState(); + assertSame(provider, currentState.provider); + assertEquals(PROVIDER_STATE_ENABLED, currentState.stateEnum); + assertEquals(config, currentState.currentUserConfiguration); + mTestThreadingDomain.assertSingleImmediateQueueItem(); + // Entering enabled() does not trigger an onProviderStateChanged() as it is requested by the + // controller. + mTestController.assertProviderChangeNotTriggered(); + + // Check the queued runnable causes the provider to go into perm failed state. + mTestThreadingDomain.executeNext(); + + // Entering perm failed triggers an onProviderStateChanged() as it is asynchronously + // triggered. + mTestController.assertProviderChangeTriggered(PROVIDER_STATE_PERM_FAILED); + } + + /** A test stand-in for the {@link LocationTimeZoneProviderController}. */ + private static class TestController extends LocationTimeZoneProviderController { + + private TestState<ProviderState> mProviderState = new TestState<>(); + + TestController(ThreadingDomain threadingDomain) { + super(threadingDomain); + } + + @Override + void initialize(Environment environment, Callback callback) { + // Not needed for provider testing. + } + + @Override + void onConfigChanged() { + // Not needed for provider testing. + } + + void onProviderStateChange(ProviderState providerState) { + this.mProviderState.set(providerState); + } + + @Override + public void dump(IndentingPrintWriter pw, String[] args) { + // Not needed for provider testing. + } + + void assertProviderChangeTriggered(int expectedStateEnum) { + assertEquals(expectedStateEnum, mProviderState.getLatest().stateEnum); + mProviderState.commitLatest(); + } + + public void assertProviderChangeNotTriggered() { + mProviderState.assertHasNotBeenSet(); + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java b/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java new file mode 100644 index 000000000000..192ade7847b0 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import android.annotation.UserIdInt; + +import com.android.server.timezonedetector.ConfigurationInternal; + +/** Shared test support code for this package. */ +final class TestSupport { + static final @UserIdInt int USER1_ID = 9999; + + static final ConfigurationInternal USER1_CONFIG_GEO_DETECTION_ENABLED = + createUserConfig(USER1_ID, true); + + static final ConfigurationInternal USER1_CONFIG_GEO_DETECTION_DISABLED = + createUserConfig(USER1_ID, false); + + static final @UserIdInt int USER2_ID = 1234567890; + + static final ConfigurationInternal USER2_CONFIG_GEO_DETECTION_ENABLED = + createUserConfig(USER2_ID, true); + + static final ConfigurationInternal USER2_CONFIG_GEO_DETECTION_DISABLED = + createUserConfig(USER2_ID, false); + + private TestSupport() { + } + + private static ConfigurationInternal createUserConfig( + @UserIdInt int userId, boolean geoDetectionEnabled) { + return new ConfigurationInternal.Builder(userId) + .setUserConfigAllowed(true) + .setAutoDetectionSupported(true) + .setAutoDetectionEnabled(true) + .setLocationEnabled(true) + .setGeoDetectionEnabled(geoDetectionEnabled) + .build(); + } +} diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java b/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java new file mode 100644 index 000000000000..70ff22d17513 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.location.timezone; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.annotation.NonNull; +import android.annotation.Nullable; + +import java.time.Duration; +import java.util.LinkedList; +import java.util.Objects; + +/** + * A ThreadingDomain that simulates idealized post() semantics. Execution takes place in zero time, + * exactly when scheduled, when the test code explicitly requests it. Execution takes place on the + * test's main thread. + */ +class TestThreadingDomain extends ThreadingDomain { + + static class QueuedRunnable { + @NonNull public final Runnable runnable; + @Nullable public final Object token; + public final long executionTimeMillis; + + QueuedRunnable(@NonNull Runnable runnable, @Nullable Object token, + long executionTimeMillis) { + this.runnable = Objects.requireNonNull(runnable); + this.token = token; + this.executionTimeMillis = executionTimeMillis; + } + + @Override + public String toString() { + return "QueuedRunnable{" + + "runnable=" + runnable + + ", token=" + token + + ", executionTimeMillis=" + executionTimeMillis + + '}'; + } + } + + private long mCurrentTimeMillis; + private LinkedList<QueuedRunnable> mQueue = new LinkedList<>(); + + TestThreadingDomain() { + // Pick an arbitrary time. + mCurrentTimeMillis = 123456L; + } + + @Override + Thread getThread() { + return Thread.currentThread(); + } + + @Override + void post(Runnable r) { + mQueue.add(new QueuedRunnable(r, null, mCurrentTimeMillis)); + } + + @Override + void postDelayed(Runnable r, long delayMillis) { + mQueue.add(new QueuedRunnable(r, null, mCurrentTimeMillis + delayMillis)); + } + + @Override + void postDelayed(Runnable r, Object token, long delayMillis) { + mQueue.add(new QueuedRunnable(r, token, mCurrentTimeMillis + delayMillis)); + } + + @Override + void removeQueuedRunnables(Object token) { + mQueue.removeIf(runnable -> runnable.token != null && runnable.token.equals(token)); + } + + void assertSingleDelayedQueueItem(Duration expectedDelay) { + assertQueueLength(1); + assertNextQueueItemIsDelayed(expectedDelay); + } + + void assertSingleImmediateQueueItem() { + assertQueueLength(1); + assertNextQueueItemIsImmediate(); + } + + void assertQueueLength(int expectedLength) { + assertEquals(expectedLength, mQueue.size()); + } + + void assertNextQueueItemIsImmediate() { + assertTrue(getNextQueueItemDelayMillis() == 0); + } + + void assertNextQueueItemIsDelayed(Duration expectedDelay) { + assertTrue(getNextQueueItemDelayMillis() == expectedDelay.toMillis()); + } + + void assertQueueEmpty() { + assertTrue(mQueue.isEmpty()); + } + + long getNextQueueItemDelayMillis() { + assertQueueLength(1); + return mQueue.getFirst().executionTimeMillis - mCurrentTimeMillis; + } + + void executeNext() { + assertQueueLength(1); + + QueuedRunnable queued = mQueue.removeFirst(); + mCurrentTimeMillis = queued.executionTimeMillis; + queued.runnable.run(); + } +} diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java index 7049efa1cc2f..97b8360172f2 100644 --- a/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java +++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java @@ -76,7 +76,7 @@ public class TestState<T> { /** Asserts the number of times {@link #set} has been called. */ public void assertChangeCount(int expectedCount) { - assertEquals(expectedCount, mValues.size()); + assertEquals(expectedCount, getChangeCount()); } /** @@ -89,4 +89,25 @@ public class TestState<T> { } return mInitialValue; } + + /** Returns the number of times {@link #set} has been called. */ + public int getChangeCount() { + return mValues.size(); + } + + /** + * Returns an historic value of the state. Values for {@code age} can be from {@code 0}, the + * latest value, through {@code getChangeCount() - 1}, which returns the oldest change, to + * {@code getChangeCount()}, which returns the initial value. Values outside of this range will + * cause {@link IndexOutOfBoundsException} to be thrown. + */ + public T getPrevious(int age) { + int size = mValues.size(); + if (age < size) { + return mValues.get(size - 1 - age); + } else if (age == size) { + return mInitialValue; + } + throw new IndexOutOfBoundsException("age=" + age + " is too big."); + } } |