From 9cb6403c047e61ff1078047f5e691de6c6ee7748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Tue, 5 Jul 2022 19:29:16 +0100 Subject: [PATCH] Mi Band 4: Add password support --- .../DeviceSpecificSettingsFragment.java | 9 +- .../password/PasswordCapabilityImpl.java | 145 ++++++++++++++++++ .../devices/AbstractDeviceCoordinator.java | 6 + .../devices/DeviceCoordinator.java | 3 + .../devices/huami/HuamiCoordinator.java | 11 ++ .../huami/miband4/MiBand4Coordinator.java | 7 + .../huami/miband6/MiBand6Coordinator.java | 8 + .../service/devices/huami/HuamiSupport.java | 40 +++++ app/src/main/res/drawable/ic_password.xml | 5 + app/src/main/res/values/strings.xml | 5 + .../main/res/xml/devicesettings_password.xml | 24 +++ 11 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java create mode 100644 app/src/main/res/drawable/ic_password.xml create mode 100644 app/src/main/res/xml/devicesettings_password.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index d1f6060b9..dd4ff86a3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -52,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.CalBlacklistActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; @@ -214,6 +215,8 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp } private void setChangeListener() { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device); + final Prefs prefs = new Prefs(getPreferenceManager().getSharedPreferences()); String disconnectNotificationState = prefs.getString(PREF_DISCONNECT_NOTIFICATION, PREF_DO_NOT_DISTURB_OFF); boolean disconnectNotificationScheduled = disconnectNotificationState.equals(PREF_DO_NOT_DISTURB_SCHEDULED); @@ -731,6 +734,8 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp setInputTypeFor(DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR, InputType.TYPE_CLASS_NUMBER); setInputTypeFor(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, InputType.TYPE_CLASS_NUMBER); + new PasswordCapabilityImpl().registerPreferences(getContext(), coordinator.getPasswordCapability(), this); + String deviceActionsFellSleepSelection = prefs.getString(PREF_DEVICE_ACTION_FELL_SLEEP_SELECTION, PREF_DEVICE_ACTION_SELECTION_OFF); final Preference deviceActionsFellSleep = findPreference(PREF_DEVICE_ACTION_FELL_SLEEP_SELECTION); final Preference deviceActionsFellSleepBroadcast = findPreference(PREF_DEVICE_ACTION_FELL_SLEEP_BROADCAST); @@ -748,7 +753,7 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp if (deviceActionsFellSleepBroadcast != null) { deviceActionsFellSleepBroadcast.setEnabled(deviceActionsFellSleepSelectionBroadcast); } - + String deviceActionsWokeUpSelection = prefs.getString(PREF_DEVICE_ACTION_WOKE_UP_SELECTION, PREF_DEVICE_ACTION_SELECTION_OFF); final Preference deviceActionsWokeUp = findPreference(PREF_DEVICE_ACTION_WOKE_UP_SELECTION); final Preference deviceActionsWokeUpBroadcast = findPreference(PREF_DEVICE_ACTION_WOKE_UP_BROADCAST); @@ -766,7 +771,7 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp if (deviceActionsWokeUpBroadcast != null) { deviceActionsWokeUpBroadcast.setEnabled(deviceActionsWokeUpSelectionBroadcast); } - + String deviceActionsStartNonWearSelection = prefs.getString(PREF_DEVICE_ACTION_START_NON_WEAR_SELECTION, PREF_DEVICE_ACTION_SELECTION_OFF); final Preference deviceActionsStartNonWear = findPreference(PREF_DEVICE_ACTION_START_NON_WEAR_SELECTION); final Preference deviceActionsStartNonWearBroadcast = findPreference(PREF_DEVICE_ACTION_START_NON_WEAR_BROADCAST); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java new file mode 100644 index 000000000..adaea4027 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java @@ -0,0 +1,145 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.password; + +import android.content.Context; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.Spanned; +import android.text.TextWatcher; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.preference.EditTextPreference; + +import java.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; + +public class PasswordCapabilityImpl { + public static final String PREF_PASSWORD = "pref_password"; + public static final String PREF_PASSWORD_ENABLED = "pref_password_enabled"; + + public static enum Mode { + NONE, + NUMBERS_4_DIGITS_1_TO_4, + NUMBERS_6, + } + + public void registerPreferences(final Context context, final Mode mode, final DeviceSpecificSettingsHandler handler) { + if (mode == Mode.NONE) { + return; + } + + final EditTextPreference password = handler.findPreference(PREF_PASSWORD); + if (password == null) { + return; + } + + handler.addPreferenceHandlerFor(PREF_PASSWORD); + handler.addPreferenceHandlerFor(PREF_PASSWORD_ENABLED); + + switch (mode) { + case NUMBERS_6: + password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary); + break; + case NUMBERS_4_DIGITS_1_TO_4: + password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary); + break; + default: + break; + } + + password.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() { + @Override + public void onBindEditText(@NonNull final EditText editText) { + final int expectedLength; + final List inputFilters = new ArrayList<>(); + + switch (mode) { + case NUMBERS_6: + password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary); + editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + expectedLength = 6; + break; + case NUMBERS_4_DIGITS_1_TO_4: + password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary); + editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + expectedLength = 4; + inputFilters.add(new InputFilter_Digits_1to4()); + break; + default: + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + expectedLength = -1; + break; + } + + if (expectedLength != -1) { + inputFilters.add(new InputFilter.LengthFilter(expectedLength)); + } + + editText.setSelection(editText.getText().length()); + editText.setFilters(inputFilters.toArray(new InputFilter[0])); + editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength)); + } + }); + } + + private static class InputFilter_Digits_1to4 implements InputFilter { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + for (int i = start; i < end; i++) { + if (!Character.isDigit(source.charAt(i))) { + return ""; + } + + if (source.charAt(i) < '1' || source.charAt(i) > '4') { + return ""; + } + } + return null; + } + } + + private static class ExpectedLengthTextWatcher implements TextWatcher { + private final EditText editText; + private final int expectedLength; + + private ExpectedLengthTextWatcher(final EditText editText, final int expectedLength) { + this.editText = editText; + this.expectedLength = expectedLength; + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + } + + @Override + public void afterTextChanged(final Editable editable) { + editText.getRootView().findViewById(android.R.id.button1) + .setEnabled(expectedLength == -1 || editable.length() == expectedLength); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index dc631621c..6c427e723 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -40,6 +40,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; @@ -315,4 +316,9 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { public boolean supportsPowerOff() { return false; } + + @Override + public PasswordCapabilityImpl.Mode getPasswordCapability() { + return PasswordCapabilityImpl.Mode.NONE; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index aee7b5ad0..0e37517cb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -33,6 +33,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -424,4 +425,6 @@ public interface DeviceCoordinator { BatteryConfig[] getBatteryConfig(); boolean supportsPowerOff(); + + PasswordCapabilityImpl.Mode getPasswordCapability(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java index cc517c788..2fe91576b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java @@ -44,6 +44,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.miband.DateTimeDisplay; @@ -278,6 +279,16 @@ public abstract class HuamiCoordinator extends AbstractDeviceCoordinator { return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING, false); } + public static boolean getPasswordEnabled(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getBoolean(PasswordCapabilityImpl.PREF_PASSWORD_ENABLED, false); + } + + public static String getPassword(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getString(PasswordCapabilityImpl.PREF_PASSWORD, null); + } + public static boolean getHeartrateAlert(String deviceAddress) throws IllegalArgumentException { Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED, false); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java index 83e9730a3..4a4661cd4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java @@ -27,6 +27,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; @@ -100,6 +101,7 @@ public class MiBand4Coordinator extends HuamiCoordinator { R.xml.devicesettings_liftwrist_display, R.xml.devicesettings_inactivity_dnd, R.xml.devicesettings_swipeunlock, + R.xml.devicesettings_password, R.xml.devicesettings_sync_calendar, R.xml.devicesettings_reserve_reminders_calendar, R.xml.devicesettings_expose_hr_thirdparty, @@ -139,4 +141,9 @@ public class MiBand4Coordinator extends HuamiCoordinator { public int getBondingStyle() { return BONDING_STYLE_REQUIRE_KEY; } + + @Override + public PasswordCapabilityImpl.Mode getPasswordCapability() { + return PasswordCapabilityImpl.Mode.NUMBERS_4_DIGITS_1_TO_4; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java index 26f7e703f..77efb590e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.Logger; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; @@ -96,6 +97,7 @@ public class MiBand6Coordinator extends HuamiCoordinator { R.xml.devicesettings_liftwrist_display_sensitivity, R.xml.devicesettings_inactivity_dnd, R.xml.devicesettings_swipeunlock, + // TODO: test on the Mi Band 6 R.xml.devicesettings_password, R.xml.devicesettings_sync_calendar, R.xml.devicesettings_reserve_reminders_calendar, R.xml.devicesettings_expose_hr_thirdparty, @@ -137,4 +139,10 @@ public class MiBand6Coordinator extends HuamiCoordinator { public int getBondingStyle() { return BONDING_STYLE_REQUIRE_KEY; } + + // TODO: Test on the Mi Band 6 + // @Override + // public PasswordCapabilityImpl.Mode getPasswordCapability() { + // return PasswordCapabilityImpl.Mode.NUMBERS_6; + // } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java index bbd8fb20f..99144d2f0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java @@ -71,6 +71,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; @@ -666,6 +667,36 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { // not supported } + private HuamiSupport setPassword(final TransactionBuilder builder) { + final boolean passwordEnabled = HuamiCoordinator.getPasswordEnabled(gbDevice.getAddress()); + final String password = HuamiCoordinator.getPassword(gbDevice.getAddress()); + + LOG.info("Setting password: {}, {}", passwordEnabled, password); + + if (password == null || password.isEmpty()) { + LOG.warn("Invalid password: {}", password); + return this; + } + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + baos.write(ENDPOINT_DISPLAY); + baos.write(0x21); + baos.write(0x00); + baos.write((byte) (passwordEnabled ? 0x01 : 0x00)); + baos.write(password.getBytes()); + baos.write(0x00); + } catch (final IOException e) { + LOG.error("Failed to build password command", e); + return this; + } + + writeToConfiguration(builder, baos.toByteArray()); + + return this; + } + /** * Part of device initialization process. Do not call manually. * @@ -2728,6 +2759,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { case PREF_HEARTRATE_STRESS_MONITORING: setHeartrateStressMonitoring(builder); break; + case PasswordCapabilityImpl.PREF_PASSWORD: + case PasswordCapabilityImpl.PREF_PASSWORD_ENABLED: + setPassword(builder); + break; } builder.queue(getQueue()); } catch (IOException e) { @@ -3854,6 +3889,8 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } public void phase3Initialize(TransactionBuilder builder) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + LOG.info("phase3Initialize..."); setDateDisplay(builder); setTimeFormat(builder); @@ -3877,6 +3914,9 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { setHeartrateMeasurementInterval(builder, HuamiCoordinator.getHeartRateMeasurementInterval(getDevice().getAddress())); sendReminders(builder); setWorldClocks(builder); + if (!PasswordCapabilityImpl.Mode.NONE.equals(coordinator.getPasswordCapability())) { + setPassword(builder); + } requestAlarms(builder); } diff --git a/app/src/main/res/drawable/ic_password.xml b/app/src/main/res/drawable/ic_password.xml new file mode 100644 index 000000000..072f0ee04 --- /dev/null +++ b/app/src/main/res/drawable/ic_password.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2c28eaff..3805107fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -718,6 +718,11 @@ Disable inactivity warnings for a time interval Heart Rate Monitoring Configure heart rate monitoring + Password + Lock the band with a password when removed from the wrist + Password Enabled + The password must have 4 digits, using numbers 1 to 4 + The password must have 6 digits, using only numbers Configure heart rate monitoring and alert thresholds Start time End time diff --git a/app/src/main/res/xml/devicesettings_password.xml b/app/src/main/res/xml/devicesettings_password.xml new file mode 100644 index 000000000..e8bface25 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_password.xml @@ -0,0 +1,24 @@ + + + + + + + + +