diff --git a/res/xml/stylus_usi_details_fragment.xml b/res/xml/stylus_usi_details_fragment.xml new file mode 100644 index 00000000000..8a1d036c80e --- /dev/null +++ b/res/xml/stylus_usi_details_fragment.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/com/android/settings/connecteddevice/stylus/StylusUsiDetailsFragment.java b/src/com/android/settings/connecteddevice/stylus/StylusUsiDetailsFragment.java new file mode 100644 index 00000000000..4691a5b79e3 --- /dev/null +++ b/src/com/android/settings/connecteddevice/stylus/StylusUsiDetailsFragment.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2022 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.settings.connecteddevice.stylus; + +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.hardware.input.InputManager; +import android.view.InputDevice; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.android.settings.R; +import com.android.settings.dashboard.DashboardFragment; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.core.lifecycle.Lifecycle; + +import java.util.ArrayList; +import java.util.List; + +/** Controls the USI stylus details and provides updates to individual controllers. */ +public class StylusUsiDetailsFragment extends DashboardFragment { + private static final String TAG = StylusUsiDetailsFragment.class.getSimpleName(); + private static final String KEY_DEVICE_INPUT_ID = "device_input_id"; + + @VisibleForTesting + @Nullable + InputDevice mInputDevice; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + int inputDeviceId = getArguments().getInt(KEY_DEVICE_INPUT_ID); + InputManager im = context.getSystemService(InputManager.class); + mInputDevice = im.getInputDevice(inputDeviceId); + + super.onAttach(context); + if (mInputDevice == null) { + finish(); + } + } + + + @Override + public int getMetricsCategory() { + // TODO(b/261988317): for new SettingsEnum for this page + return SettingsEnums.BLUETOOTH_DEVICE_DETAILS; + } + + @Override + protected String getLogTag() { + return TAG; + } + + @Override + protected int getPreferenceScreenResId() { + return R.xml.stylus_usi_details_fragment; + } + + @Override + protected List createPreferenceControllers(Context context) { + ArrayList controllers = new ArrayList<>(); + if (mInputDevice != null) { + Lifecycle lifecycle = getSettingsLifecycle(); + controllers.add(new StylusUsiHeaderController(context, mInputDevice)); + controllers.add(new StylusDevicesController(context, mInputDevice, lifecycle)); + } + return controllers; + } +} diff --git a/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderController.java b/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderController.java new file mode 100644 index 00000000000..826cc1f6a30 --- /dev/null +++ b/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderController.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2022 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.settings.connecteddevice.stylus; + +import android.content.Context; +import android.hardware.BatteryState; +import android.hardware.input.InputManager; +import android.os.Bundle; +import android.view.InputDevice; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.core.BasePreferenceController; +import com.android.settingslib.core.lifecycle.LifecycleObserver; +import com.android.settingslib.core.lifecycle.events.OnCreate; +import com.android.settingslib.core.lifecycle.events.OnDestroy; +import com.android.settingslib.widget.LayoutPreference; + +import java.text.NumberFormat; + +/** + * This class adds a header for USI stylus devices with a heading, icon, and battery level. + * As opposed to the bluetooth device headers, this USI header gets its battery values + * from {@link InputManager} APIs, rather than the bluetooth battery levels. + */ +public class StylusUsiHeaderController extends BasePreferenceController implements + InputManager.InputDeviceBatteryListener, LifecycleObserver, OnCreate, OnDestroy { + + private static final String KEY_STYLUS_USI_HEADER = "stylus_usi_header"; + private static final String TAG = StylusUsiHeaderController.class.getSimpleName(); + + private final InputManager mInputManager; + private final InputDevice mInputDevice; + + private LayoutPreference mHeaderPreference; + + + public StylusUsiHeaderController(Context context, InputDevice inputDevice) { + super(context, KEY_STYLUS_USI_HEADER); + mInputDevice = inputDevice; + mInputManager = context.getSystemService(InputManager.class); + } + + @Override + public void displayPreference(PreferenceScreen screen) { + mHeaderPreference = screen.findPreference(getPreferenceKey()); + View view = mHeaderPreference.findViewById(R.id.entity_header); + TextView titleView = view.findViewById(R.id.entity_header_title); + titleView.setText(R.string.stylus_usi_header_title); + + ImageView iconView = mHeaderPreference.findViewById(R.id.entity_header_icon); + if (iconView != null) { + // TODO(b/250909304): get proper icon once VisD ready + iconView.setImageResource(R.drawable.circle); + iconView.setContentDescription("Icon for stylus"); + } + refresh(); + super.displayPreference(screen); + } + + @Override + public void updateState(Preference preference) { + refresh(); + } + + private void refresh() { + BatteryState batteryState = mInputDevice.getBatteryState(); + View view = mHeaderPreference.findViewById(R.id.entity_header); + TextView summaryView = view.findViewById(R.id.entity_header_summary); + + if (isValidBatteryState(batteryState)) { + summaryView.setVisibility(View.VISIBLE); + summaryView.setText( + NumberFormat.getPercentInstance().format(batteryState.getCapacity())); + } else { + summaryView.setVisibility(View.INVISIBLE); + } + } + + /** + * This determines if a battery state is 'stale', as indicated by the presence of + * battery values. + * + * A USI battery state is valid (and present) if a USI battery value has been pulled + * within the last 1 hour of a stylus touching/hovering on the screen. The header shows + * battery values in this case, Conversely, a stale battery state means no USI battery + * value has been detected within the last 1 hour. Thus, the USI stylus preference will + * not be shown in Settings, and accordingly, the USI battery state won't surface. + * + * @param batteryState Latest battery state pulled from the kernel + */ + private boolean isValidBatteryState(BatteryState batteryState) { + return batteryState != null + && batteryState.isPresent() + && batteryState.getCapacity() > 0f; + } + + @Override + public int getAvailabilityStatus() { + return AVAILABLE; + } + + @Override + public String getPreferenceKey() { + return KEY_STYLUS_USI_HEADER; + } + + @Override + public void onBatteryStateChanged(int deviceId, long eventTimeMillis, + @NonNull BatteryState batteryState) { + refresh(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + mInputManager.addInputDeviceBatteryListener(mInputDevice.getId(), + mContext.getMainExecutor(), this); + } + + @Override + public void onDestroy() { + mInputManager.removeInputDeviceBatteryListener(mInputDevice.getId(), + this); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderControllerTest.java new file mode 100644 index 00000000000..27b1de52a11 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/stylus/StylusUsiHeaderControllerTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2022 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.settings.connecteddevice.stylus; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.hardware.BatteryState; +import android.hardware.input.InputManager; +import android.os.Bundle; +import android.view.InputDevice; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settingslib.widget.LayoutPreference; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class StylusUsiHeaderControllerTest { + + private Context mContext; + private StylusUsiHeaderController mController; + private LayoutPreference mLayoutPreference; + private PreferenceScreen mScreen; + private InputDevice mInputDevice; + + @Mock + private InputManager mInputManager; + @Mock + private BatteryState mBatteryState; + @Mock + private Bundle mBundle; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + InputDevice device = new InputDevice.Builder().setId(1).setSources( + InputDevice.SOURCE_BLUETOOTH_STYLUS).build(); + mInputDevice = spy(device); + when(mInputDevice.getBatteryState()).thenReturn(mBatteryState); + when(mBatteryState.getCapacity()).thenReturn(1f); + when(mBatteryState.isPresent()).thenReturn(true); + + mContext = spy(ApplicationProvider.getApplicationContext()); + when(mContext.getSystemService(InputManager.class)).thenReturn(mInputManager); + mController = new StylusUsiHeaderController(mContext, mInputDevice); + + PreferenceManager preferenceManager = new PreferenceManager(mContext); + mLayoutPreference = new LayoutPreference(mContext, + LayoutInflater.from(mContext).inflate(R.layout.advanced_bt_entity_header, null)); + mLayoutPreference.setKey(mController.getPreferenceKey()); + + mScreen = preferenceManager.createPreferenceScreen(mContext); + mScreen.addPreference(mLayoutPreference); + + } + + @Test + public void onCreate_registersBatteryListener() { + mController.onCreate(mBundle); + + verify(mInputManager).addInputDeviceBatteryListener(mInputDevice.getId(), + mContext.getMainExecutor(), + mController); + } + + @Test + public void onDestroy_unregistersBatteryListener() { + mController.onDestroy(); + + verify(mInputManager).removeInputDeviceBatteryListener(mInputDevice.getId(), + mController); + } + + @Test + public void displayPreference_showsCorrectTitle() { + mController.displayPreference(mScreen); + + assertThat(((TextView) mLayoutPreference.findViewById( + R.id.entity_header_title)).getText().toString()).isEqualTo( + mContext.getString(R.string.stylus_usi_header_title)); + } + + @Test + public void displayPreference_hasBattery_showsCorrectBatterySummary() { + mController.displayPreference(mScreen); + + assertThat(mLayoutPreference.findViewById( + R.id.entity_header_summary).getVisibility()).isEqualTo(View.VISIBLE); + assertThat(((TextView) mLayoutPreference.findViewById( + R.id.entity_header_summary)).getText().toString()).isEqualTo( + "100%"); + } + + @Test + public void displayPreference_noBattery_showsEmptySummary() { + when(mBatteryState.isPresent()).thenReturn(false); + + mController.displayPreference(mScreen); + + assertThat(mLayoutPreference.findViewById( + R.id.entity_header_summary).getVisibility()).isEqualTo(View.INVISIBLE); + } + + @Test + public void displayPreference_invalidCapacity_showsEmptySummary() { + when(mBatteryState.getCapacity()).thenReturn(-1f); + + mController.displayPreference(mScreen); + + assertThat(mLayoutPreference.findViewById( + R.id.entity_header_summary).getVisibility()).isEqualTo(View.INVISIBLE); + } + + @Test + public void onBatteryStateChanged_updatesSummary() { + mController.displayPreference(mScreen); + + when(mBatteryState.getCapacity()).thenReturn(0.2f); + mController.onBatteryStateChanged(mInputDevice.getId(), + System.currentTimeMillis(), mBatteryState); + + assertThat(((TextView) mLayoutPreference.findViewById( + R.id.entity_header_summary)).getText().toString()).isEqualTo( + "20%"); + } +}