diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml index c4575e4555..100bc79a79 100644 --- a/.idea/dictionaries/t.xml +++ b/.idea/dictionaries/t.xml @@ -1,6 +1,7 @@ + aawireless abettenburg adhikary afanasev diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 0eb7e3c534..e38e4abeb7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -325,6 +325,12 @@ public class DeviceSettingsPreferenceConst { public static final String WIFI_HOTSPOT_STOP = "wifi_hotspot_stop"; public static final String WIFI_HOTSPOT_STATUS = "wifi_hotspot_status"; + public static final String PREF_WIFI_FREQUENCY = "wifi_frequency"; + public static final String PREF_WIFI_CHANNEL_2_4 = "wifi_channel_2_4"; + public static final String PREF_WIFI_CHANNEL_5 = "wifi_channel_5"; + + public static final String PREF_COUNTRY = "country"; + public static final String PREF_APP_LOGS_START = "pref_app_logs_start"; public static final String PREF_APP_LOGS_STOP = "pref_app_logs_stop"; public static final String PREF_DEVICE_LOGS_TOGGLE = "device_logs_enabled"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java index 62b634b0de..03052f4f92 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java @@ -16,6 +16,7 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings; +import android.content.Context; import android.text.InputFilter; import android.text.InputType; import android.text.Spanned; @@ -24,6 +25,9 @@ import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.MultiSelectListPreference; import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -241,4 +245,40 @@ public final class DeviceSettingsUtils { return ""; } } + + public static void addConfirmablePreferenceHandlerFor(final DeviceSpecificSettingsHandler handler, + final String preferenceKey, + final int alertMessage) { + final Preference pref = handler.findPreference(preferenceKey); + if (pref == null) { + return; + } + pref.setOnPreferenceChangeListener((preference, newValue) -> { + final Context context = handler.getContext(); + final CharSequence preferenceName = pref.getTitle(); + final String title = context.getString(R.string.earfun_change_confirm_title, preferenceName); + final String message = context.getString(alertMessage); + + new MaterialAlertDialogBuilder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(handler.getContext().getString(R.string.ok), (dialog, which) -> { + if (pref instanceof EditTextPreference) { + ((EditTextPreference) pref).setText(newValue.toString()); + } else if (pref instanceof ListPreference) { + ((ListPreference) pref).setValue(newValue.toString()); + } else if (pref instanceof SwitchPreferenceCompat) { + ((SwitchPreferenceCompat) pref).setChecked((Boolean) newValue); + } else { + LOG.error("Unsupported preference type {} for confirmable handler: {}", pref.getClass(), pref); + return; + } + handler.notifyPreferenceChanged(preferenceKey); + }) + .setNegativeButton(handler.getContext().getString(R.string.Cancel), (dialog, which) -> dialog.dismiss()) + .create() + .show(); + return false; + }); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java index a64abb017b..48332fbfa7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java @@ -49,4 +49,13 @@ public interface DeviceSpecificSettingsCustomizer extends Parcelable { * Keys of preferences which should print its values as a summary below the preference name. */ Set getPreferenceKeysWithSummary(); + + /** + * Called when the associated {@link nodomain.freeyourgadget.gadgetbridge.impl.GBDevice} changes. + * + * @param handler the {@link DeviceSpecificSettingsHandler} + */ + default void onDeviceChanged(final DeviceSpecificSettingsHandler handler) { + // Nothing to do by default + } } 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 2db7343bf8..3668b1e9b7 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 @@ -38,12 +38,18 @@ import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PR import static nodomain.freeyourgadget.gadgetbridge.devices.moyoung.MoyoungConstants.PREF_MOYOUNG_DEVICE_VERSION; import static nodomain.freeyourgadget.gadgetbridge.devices.moyoung.MoyoungConstants.PREF_MOYOUNG_WATCH_FACE; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.media.AudioManager; import android.os.Bundle; import android.text.InputType; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; @@ -124,6 +130,42 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i setArguments(args); } + private final BroadcastReceiver mDeviceUpdateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + if (GBDevice.ACTION_DEVICE_CHANGED.equals(intent.getAction())) { + final GBDevice changedDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE); + if (changedDevice != null && changedDevice.equals(device)) { + device = changedDevice; // update the state + if (deviceSpecificSettingsCustomizer != null) { + LOG.debug("{} changed, notifying customizer", changedDevice); + deviceSpecificSettingsCustomizer.onDeviceChanged(DeviceSpecificSettingsFragment.this); + } + } + } + } + }; + + @NonNull + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + final View view = super.onCreateView(inflater, container, savedInstanceState); + + final IntentFilter commandFilter = new IntentFilter(); + commandFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED); + LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mDeviceUpdateReceiver, commandFilter); + + return view; + } + + @Override + public void onDestroyView() { + LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mDeviceUpdateReceiver); + super.onDestroyView(); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Bundle arguments = getArguments(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java index 3e5f4f0d94..18e2655374 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java @@ -21,6 +21,7 @@ import androidx.annotation.XmlRes; import nodomain.freeyourgadget.gadgetbridge.R; public enum DeviceSpecificSettingsScreen { + ADVANCED_SETTINGS("pref_screen_advanced_settings", R.xml.devicesettings_root_advanced_settings), ACTIVITY_INFO("pref_screen_activity_info", R.xml.devicesettings_root_activity_info), AUDIO("pref_screen_audio", R.xml.devicesettings_root_audio), AUTHENTICATION("pref_screen_authentication", R.xml.devicesettings_root_authentication), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SimpleIconListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SimpleIconListAdapter.java new file mode 100644 index 0000000000..378508369a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SimpleIconListAdapter.java @@ -0,0 +1,61 @@ +/* Copyright (C) 2025 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.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.model.RunnableListIconItem; + +public class SimpleIconListAdapter extends ArrayAdapter { + private final Context context; + private final List items; + + public SimpleIconListAdapter(final Context context, final List items) { + super(context, R.layout.simple_list_item_with_icon, items); + this.context = context; + this.items = items; + } + + @NonNull + @Override + public View getView(final int position, View convertView, @NonNull final ViewGroup parent) { + if (convertView == null) { + LayoutInflater inflater = LayoutInflater.from(context); + convertView = inflater.inflate(R.layout.simple_list_item_with_icon, parent, false); + } + + final ImageView icon = convertView.findViewById(R.id.option_icon); + final TextView title = convertView.findViewById(R.id.option_title); + + final RunnableListIconItem item = items.get(position); + icon.setImageResource(item.getIconResId()); + title.setText(item.getTitle()); + + return convertView; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessCoordinator.java new file mode 100644 index 0000000000..1e4872fbfe --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessCoordinator.java @@ -0,0 +1,112 @@ +/* Copyright (C) 2025 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.devices.aawireless; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.aawireless.AAWirelessPrefs; +import nodomain.freeyourgadget.gadgetbridge.service.devices.aawireless.AAWirelessSupport; +import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; + +public class AAWirelessCoordinator extends AbstractBLClassicDeviceCoordinator { + @Override + public boolean isExperimental() { + return true; + } + + @Override + protected void deleteDevice(@NonNull final GBDevice gbDevice, + @NonNull final Device device, + @NonNull final DaoSession session) throws GBException { + } + + @Override + protected Pattern getSupportedDeviceName() { + // AAW 1 - AAWireless-xUkL1YH0 + // AAW 2 - Normal mode: AAWireless-12345abc + // AAW 2 - Dongle mode: AndroidAuto-AAW12345abc + return Pattern.compile("^(AAWireless-|AndroidAuto-AAW)[0-9a-zA-Z]+$"); + } + + @Override + public String getManufacturer() { + return "AAWireless"; + } + + @NonNull + @Override + public Class getDeviceSupportClass(final GBDevice device) { + return AAWirelessSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_aawireless; + } + + @Override + public int getBatteryCount(final GBDevice device) { + return 0; + } + + @Override + public BatteryConfig[] getBatteryConfig(final GBDevice device) { + return new BatteryConfig[0]; + } + + @Override + public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) { + final DevicePrefs devicePrefs = GBApplication.getDevicePrefs(device); + + final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings(); + + deviceSpecificSettings.addRootScreen(R.xml.devicesettings_aawireless_paired_phones); + deviceSpecificSettings.addRootScreen(R.xml.devicesettings_country); + if (devicePrefs.getBoolean(AAWirelessPrefs.PREF_HAS_BUTTON, false)) { + deviceSpecificSettings.addRootScreen(R.xml.devicesettings_aawireless_button); + } + // TODO deviceSpecificSettings.addRootScreen(R.xml.devicesettings_aawireless_auto_standby); + + final List advanced = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.ADVANCED_SETTINGS); + advanced.add(R.xml.devicesettings_aawireless_advanced); + + final List connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION); + connection.add(R.xml.devicesettings_wifi_frequency_channel); + + return deviceSpecificSettings; + } + + @Override + public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) { + return new AAWirelessSettingsCustomizer(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessSettingsCustomizer.java new file mode 100644 index 0000000000..2d361d4da0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/aawireless/AAWirelessSettingsCustomizer.java @@ -0,0 +1,332 @@ +/* Copyright (C) 2025 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.devices.aawireless; + +import android.content.Context; +import android.content.Intent; +import android.os.Parcel; +import android.text.InputType; + +import androidx.annotation.NonNull; +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.adapter.SimpleIconListAdapter; +import nodomain.freeyourgadget.gadgetbridge.model.RunnableListIconItem; +import nodomain.freeyourgadget.gadgetbridge.service.devices.aawireless.AAWirelessPrefs; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.preferences.CountryCodeTextWatcher; +import nodomain.freeyourgadget.gadgetbridge.util.preferences.MinMaxTextWatcher; + +public class AAWirelessSettingsCustomizer implements DeviceSpecificSettingsCustomizer { + private static final String PREF_DUMMY_PHONE = "pref_aawireless_dummy_phone_"; + + @Override + public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) { + + } + + @Override + public void onDeviceChanged(final DeviceSpecificSettingsHandler handler) { + final PreferenceCategory pairedPhonesHeader = handler.findPreference(AAWirelessPrefs.PREF_HEADER_PAIRED_PHONES); + if (pairedPhonesHeader != null) { + setupPhoneManagement(handler, new AAWirelessPrefs(GBApplication.getDevicePrefs(handler.getDevice()).getPreferences(), handler.getDevice())); + } + + setEnabledPreferences(handler); + } + + @Override + public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs genericDevicePrefs, final String rootKey) { + final AAWirelessPrefs prefs = new AAWirelessPrefs(genericDevicePrefs.getPreferences(), handler.getDevice()); + + // Paired phone management + if (AAWirelessPrefs.PREF_SCREEN_PAIRED_PHONES.equals(rootKey)) { + setupPhoneManagement(handler, prefs); + return; + } + + // Country + handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_COUNTRY); + final EditTextPreference country = handler.findPreference(DeviceSettingsPreferenceConst.PREF_COUNTRY); + if (country != null) { + country.setOnBindEditTextListener(editText -> { + editText.addTextChangedListener(new CountryCodeTextWatcher(editText)); + editText.setSelection(editText.getText().length()); + }); + } + + // Auto-standby + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_AUTO_STANDBY_ENABLED); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_AUTO_STANDBY_DEVICE); + + // Button modes + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_BUTTON_MODE_SINGLE_CLICK); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_BUTTON_MODE_DOUBLE_CLICK); + appendKnownPhones(handler.findPreference(AAWirelessPrefs.PREF_BUTTON_MODE_SINGLE_CLICK), handler.getContext(), prefs); + appendKnownPhones(handler.findPreference(AAWirelessPrefs.PREF_BUTTON_MODE_DOUBLE_CLICK), handler.getContext(), prefs); + + // Wi-Fi frequency and channel + final String currentFrequency = prefs.getString(DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY, "5"); + final ListPreference wifiChannel24 = handler.findPreference(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4); + if (wifiChannel24 != null) { + wifiChannel24.setVisible("2.4".equals(currentFrequency)); + } + final ListPreference wifiChannel5 = handler.findPreference(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5); + if (wifiChannel5 != null) { + wifiChannel5.setVisible("5".equals(currentFrequency)); + } + handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY, (preference, newValue) -> { + if (wifiChannel24 != null) { + wifiChannel24.setVisible("2.4".equals(newValue)); + } + if (wifiChannel5 != null) { + wifiChannel5.setVisible("5".equals(newValue)); + } + return true; + }); + handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4); + handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5); + + // Advanced settings + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor( + handler, + AAWirelessPrefs.PREF_DONGLE_MODE, + R.string.pref_aawireless_dongle_confirmation + ); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_PASSTHROUGH); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_AUDIO_STUTTER_FIX); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_DPI); + final EditTextPreference dpi = handler.findPreference(AAWirelessPrefs.PREF_DPI); + if (dpi != null) { + dpi.setOnBindEditTextListener(editText -> { + editText.setInputType(InputType.TYPE_CLASS_NUMBER); + editText.addTextChangedListener(new MinMaxTextWatcher(editText, 0, 300, false)); + editText.setSelection(editText.getText().length()); + }); + } + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_DISABLE_MEDIA_SINK); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_DISABLE_TTS_SINK); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_REMOVE_TAP_RESTRICTION); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_VAG_CRASH_FIX); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_START_FIX); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_DEVELOPER_MODE); + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_AUTO_VIDEO_FOCUS); + + setEnabledPreferences(handler); + } + + private static void setEnabledPreferences(final DeviceSpecificSettingsHandler handler) { + final boolean initialized = handler.getDevice().isInitialized(); + + setEnabled(handler.findPreference(DeviceSettingsPreferenceConst.PREF_COUNTRY), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_AUTO_STANDBY_ENABLED), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_AUTO_STANDBY_DEVICE), initialized); + setEnabled(handler.findPreference(DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY), initialized); + setEnabled(handler.findPreference(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4), initialized); + setEnabled(handler.findPreference(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_BUTTON_MODE_SINGLE_CLICK), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_BUTTON_MODE_DOUBLE_CLICK), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_DONGLE_MODE), initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_PASSTHROUGH), initialized); + final SwitchPreferenceCompat passthroughPref = handler.findPreference(AAWirelessPrefs.PREF_PASSTHROUGH); + final boolean passthrough = passthroughPref != null && passthroughPref.isChecked(); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_AUDIO_STUTTER_FIX), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_DPI), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_DISABLE_MEDIA_SINK), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_DISABLE_TTS_SINK), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_REMOVE_TAP_RESTRICTION), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_VAG_CRASH_FIX), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_START_FIX), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_DEVELOPER_MODE), !passthrough && initialized); + setEnabled(handler.findPreference(AAWirelessPrefs.PREF_AUTO_VIDEO_FOCUS), !passthrough && initialized); + } + + private static void setEnabled(final Preference pref, final boolean enabled) { + if (pref != null) { + pref.setEnabled(enabled); + } + } + + private static void setupPhoneManagement(final DeviceSpecificSettingsHandler handler, final AAWirelessPrefs prefs) { + final Context context = handler.getContext(); + final int phonesCount = prefs.getPairedPhoneCount(); + + final Preference preferLastConnected = handler.findPreference(AAWirelessPrefs.PREF_PREFER_LAST_CONNECTED); + if (preferLastConnected != null) { + handler.addPreferenceHandlerFor(AAWirelessPrefs.PREF_PREFER_LAST_CONNECTED); + preferLastConnected.setEnabled(handler.getDevice().isInitialized()); + preferLastConnected.setVisible(!prefs.enableDongleMode()); + } + final PreferenceCategory pairedPhonesHeader = handler.findPreference(AAWirelessPrefs.PREF_HEADER_PAIRED_PHONES); + final Preference noPairedPhones = handler.findPreference(AAWirelessPrefs.PREF_NO_PAIRED_PHONES); + if (noPairedPhones != null) { + noPairedPhones.setVisible(phonesCount == 0); + } + + for (int i = 0; i < pairedPhonesHeader.getPreferenceCount(); i++) { + final Preference preference = pairedPhonesHeader.getPreference(i); + if (preference.getKey().startsWith(PREF_DUMMY_PHONE)) { + pairedPhonesHeader.removePreference(preference); + i--; + } + } + + for (int i = 0; i < phonesCount; i++) { + final int phonePosition = i; + final Preference prefPhone = new Preference(context); + final String phoneName = prefs.getPairedPhoneName(i); + final String phoneMac = prefs.getPairedPhoneMac(i); + prefPhone.setOnPreferenceClickListener(preference -> { + final List items = new ArrayList<>(4); + + if (!prefs.enableDongleMode()) { + items.add(new RunnableListIconItem(context.getString(R.string.switch_to_phone, phoneName), R.drawable.ic_switch_left, () -> { + final Intent intent = new Intent(AAWirelessPrefs.ACTION_PHONE_SWITCH); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_MAC, phoneMac); + intent.setPackage(BuildConfig.APPLICATION_ID); + context.sendBroadcast(intent); + })); + } + + if (phonePosition > 0) { + items.add(new RunnableListIconItem(context.getString(R.string.widget_move_up), R.drawable.ic_arrow_upward, () -> { + final Intent intent = new Intent(AAWirelessPrefs.ACTION_PHONE_SORT); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_MAC, phoneMac); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_NEW_POSITION, phonePosition - 1); + intent.setPackage(BuildConfig.APPLICATION_ID); + context.sendBroadcast(intent); + })); + } + + if (phonePosition < phonesCount - 1) { + items.add(new RunnableListIconItem(context.getString(R.string.widget_move_down), R.drawable.ic_arrow_downward, () -> { + final Intent intent = new Intent(AAWirelessPrefs.ACTION_PHONE_SORT); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_MAC, phoneMac); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_NEW_POSITION, phonePosition + 1); + intent.setPackage(BuildConfig.APPLICATION_ID); + context.sendBroadcast(intent); + })); + } + + items.add(new RunnableListIconItem(context.getString(R.string.Delete), R.drawable.ic_delete, () -> { + final Intent intent = new Intent(AAWirelessPrefs.ACTION_PHONE_DELETE); + intent.putExtra(AAWirelessPrefs.EXTRA_PHONE_MAC, phoneMac); + intent.setPackage(BuildConfig.APPLICATION_ID); + context.sendBroadcast(intent); + })); + + final SimpleIconListAdapter adapter = new SimpleIconListAdapter(context, items); + + new MaterialAlertDialogBuilder(context) + .setAdapter(adapter, (dialog, i1) -> items.get(i1).getAction().run()) + .setTitle(phoneName) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i1) -> { + }) + .create() + .show(); + return true; + }); + + prefPhone.setKey(PREF_DUMMY_PHONE + i); + prefPhone.setIcon(R.drawable.ic_smartphone); + prefPhone.setTitle(phoneName); + prefPhone.setSummary(phoneMac); + prefPhone.setEnabled(handler.getDevice().isInitialized()); + + pairedPhonesHeader.addPreference(prefPhone); + } + } + + private static void appendKnownPhones(final ListPreference listPreference, final Context context, final AAWirelessPrefs prefs) { + if (listPreference == null) { + return; + } + + final int phonesCount = prefs.getPairedPhoneCount(); + if (phonesCount <= 0) { + return; + } + + final CharSequence[] entries = listPreference.getEntries(); + final CharSequence[] entryValues = listPreference.getEntryValues(); + if (entries == null || entryValues == null || entries.length != entryValues.length) { + return; + } + + final CharSequence[] newEntries = new CharSequence[entries.length + phonesCount]; + final CharSequence[] newEntryValues = new CharSequence[entries.length + phonesCount]; + + for (int i = 0; i < entries.length; i++) { + newEntries[i] = entries[i]; + newEntryValues[i] = entryValues[i]; + } + for (int i = 0; i < phonesCount; i++) { + final String mac = prefs.getPairedPhoneMac(i); + final String name = prefs.getPairedPhoneName(i); + newEntries[entries.length + i] = context.getString(R.string.switch_to_phone, name); + newEntryValues[entries.length + i] = mac; + } + + listPreference.setEntries(newEntries); + listPreference.setEntryValues(newEntryValues); + } + + @Override + public Set getPreferenceKeysWithSummary() { + return Collections.emptySet(); + } + + public static final Creator CREATOR = new Creator<>() { + @Override + public AAWirelessSettingsCustomizer createFromParcel(final Parcel in) { + return new AAWirelessSettingsCustomizer(); + } + + @Override + public AAWirelessSettingsCustomizer[] newArray(final int size) { + return new AAWirelessSettingsCustomizer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull final Parcel parcel, final int i) { + + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java index b21d6c2c0e..15009ba5e3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java @@ -24,7 +24,6 @@ import android.content.IntentFilter; import android.os.Bundle; import android.text.InputFilter; import android.text.InputType; -import android.widget.ArrayAdapter; import android.widget.Toast; import androidx.annotation.DrawableRes; @@ -56,7 +55,9 @@ import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment; +import nodomain.freeyourgadget.gadgetbridge.adapter.SimpleIconListAdapter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.RunnableListIconItem; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -577,23 +578,50 @@ public class GarminRealtimeSettingsFragment extends AbstractPreferenceFragment { break; case 15: // sortable + delete // Add all sortable items and then continue - final String moveUpStr = activity.getString(R.string.widget_move_up); - final String moveDownStr = activity.getString(R.string.widget_move_down); - final String deleteStr = activity.getString(R.string.appmananger_app_delete); - for (int i = 0; i < entry.getSortOptions().getEntriesCount(); i++) { final GdiSettingsService.SortEntry sortEntry = entry.getSortOptions().getEntries(i); - final List sortableOptions = new ArrayList<>(3); + final Preference sortPref = new Preference(activity); + final int iFinal = i; + + final List sortableOptions = new ArrayList<>(3); if (i > 0) { - sortableOptions.add(moveUpStr); + sortableOptions.add(new RunnableListIconItem(activity.getString(R.string.widget_move_up), R.drawable.ic_arrow_upward, () -> { + sortPref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(sortEntry.getId()) + .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() + .setIndex(iFinal - 1) + ) + ); + })); } if (i < entry.getSortOptions().getEntriesCount() - 1) { - sortableOptions.add(moveDownStr); + sortableOptions.add(new RunnableListIconItem(activity.getString(R.string.widget_move_down), R.drawable.ic_arrow_downward, () -> { + sortPref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(sortEntry.getId()) + .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() + .setIndex(iFinal + 1) + ) + ); + })); } - sortableOptions.add(deleteStr); - final ArrayAdapter sortOptionsAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, sortableOptions); - final int iFinal = i; - final Preference sortPref = new Preference(activity); + sortableOptions.add(new RunnableListIconItem(activity.getString(R.string.appmananger_app_delete), R.drawable.ic_delete, () -> { + sortPref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(sortEntry.getId()) + .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() + .setDelete(true) + ) + ); + })); + final SimpleIconListAdapter sortOptionsAdapter = new SimpleIconListAdapter(activity, sortableOptions); sortPref.setTitle(sortEntry.getTitle().getText()); sortPref.setPersistent(false); sortPref.setIconSpaceReserved(false); @@ -601,40 +629,8 @@ public class GarminRealtimeSettingsFragment extends AbstractPreferenceFragment { sortPref.setOnPreferenceClickListener(preference -> { new MaterialAlertDialogBuilder(activity) .setTitle(sortPref.getTitle()) - .setAdapter(sortOptionsAdapter, (dialogInterface, j) -> { - final String option = sortableOptions.get(j); - int moveOffset = 0; - if (option.equals(moveUpStr)) { - moveOffset = -1; - } else if (option.equals(moveDownStr)) { - moveOffset = 1; - } - - if (moveOffset != 0) { - sortPref.setEnabled(false); - sendChangeRequest( - GdiSettingsService.ChangeRequest.newBuilder() - .setScreenId(screenId) - .setEntryId(sortEntry.getId()) - .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() - .setIndex(iFinal + moveOffset) - ) - ); - return; - } - - if (option.equals(deleteStr)) { - sortPref.setEnabled(false); - sendChangeRequest( - GdiSettingsService.ChangeRequest.newBuilder() - .setScreenId(screenId) - .setEntryId(sortEntry.getId()) - .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() - .setDelete(true) - ) - ); - } - }).setNegativeButton(android.R.string.cancel, null) + .setAdapter(sortOptionsAdapter, (dialogInterface, j) -> sortableOptions.get(j).getAction().run()) + .setNegativeButton(android.R.string.cancel, null) .create().show(); return true; }); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index 323d9103b9..91c02a3a8a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -29,6 +29,7 @@ package nodomain.freeyourgadget.gadgetbridge.model; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.UnknownDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.aawireless.AAWirelessCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.asteroidos.AsteroidOSDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; @@ -576,6 +577,7 @@ public enum DeviceType { GARMIN_VIVOSMART_5(GarminVivosmart5Coordinator.class), GARMIN_VIVOSPORT(GarminVivosportCoordinator.class), GREE_AC(GreeAcCoordinator.class), + AAWIRELESS(AAWirelessCoordinator.class), VIBRATISSIMO(VibratissimoCoordinator.class), SONY_SWR12(SonySWR12DeviceCoordinator.class), LIVEVIEW(LiveviewCoordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RunnableListIconItem.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RunnableListIconItem.java new file mode 100644 index 0000000000..521dfc6579 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RunnableListIconItem.java @@ -0,0 +1,46 @@ +/* Copyright (C) 2025 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.model; + +import androidx.annotation.DrawableRes; + +public class RunnableListIconItem { + private final String title; + private final int iconResId; + private final Runnable action; + + public RunnableListIconItem(final String title, + @DrawableRes final int iconResId, + final Runnable action) { + this.title = title; + this.iconResId = iconResId; + this.action = action; + } + + public String getTitle() { + return title; + } + + @DrawableRes + public int getIconResId() { + return iconResId; + } + + public Runnable getAction() { + return action; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java index 479a484dfd..bc79e1d4ae 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java @@ -50,8 +50,13 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im @Override public boolean connect() { + final UUID supportedService = getSupportedService(); + if (supportedService == null) { + throw new NullPointerException("No supported service UUID specified"); + } + if (mQueue == null) { - mQueue = new BtBRQueue(getBluetoothAdapter(), getDevice(), getContext(), this, getSupportedService(), getBufferSize()); + mQueue = new BtBRQueue(getBluetoothAdapter(), getDevice(), getContext(), this, supportedService, getBufferSize()); } return mQueue.connect(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java index d90df26f80..7518d66bdc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java @@ -95,7 +95,7 @@ public final class BtBRQueue { } }; - public BtBRQueue(BluetoothAdapter btAdapter, GBDevice gbDevice, Context context, SocketCallback socketCallback, UUID supportedService, int bufferSize) { + public BtBRQueue(BluetoothAdapter btAdapter, GBDevice gbDevice, Context context, SocketCallback socketCallback, @NonNull UUID supportedService, int bufferSize) { mBtAdapter = btAdapter; mGbDevice = gbDevice; mContext = context; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessPrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessPrefs.java new file mode 100644 index 0000000000..00c60e0d26 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessPrefs.java @@ -0,0 +1,166 @@ +/* Copyright (C) 2025 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.service.devices.aawireless; + +import android.content.SharedPreferences; + +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; + +public class AAWirelessPrefs extends DevicePrefs { + // Auto-standby + public static final String PREF_AUTO_STANDBY_ENABLED = "auto_standby_enabled"; + public static final String PREF_AUTO_STANDBY_DEVICE = "auto_standby_device"; + + // Button modes + public static final String PREF_BUTTON_MODE_SINGLE_CLICK = "button_mode_single_click"; + public static final String PREF_BUTTON_MODE_DOUBLE_CLICK = "button_mode_double_click"; + public static final String BUTTON_MODE_NONE = "none"; + public static final String BUTTON_MODE_NEXT_PHONE = "next_phone"; + public static final String BUTTON_MODE_STANDBY_ON_OFF = "standby_on_off"; + public static final String PREF_HAS_BUTTON = "aawireless_has_button"; + + // Advanced settings + public static final String PREF_DONGLE_MODE = "dongle_mode"; + public static final String PREF_PASSTHROUGH = "passthrough"; + public static final String PREF_AUDIO_STUTTER_FIX = "audio_stutter_fix"; + public static final String AUDIO_STUTTER_FIX_OFF = "off"; + public static final String AUDIO_STUTTER_FIX_LOW = "low"; + public static final String AUDIO_STUTTER_FIX_HIGH = "high"; + public static final String AUDIO_STUTTER_FIX_UNLIMITED = "unlimited"; + public static final String PREF_DPI = "dpi"; + public static final String PREF_DISABLE_MEDIA_SINK = "disable_media_sink"; + public static final String PREF_DISABLE_TTS_SINK = "disable_tts_sink"; + public static final String PREF_REMOVE_TAP_RESTRICTION = "remove_tap_restriction"; + public static final String PREF_VAG_CRASH_FIX = "vag_crash_fix"; + public static final String PREF_START_FIX = "start_fix"; + public static final String PREF_DEVELOPER_MODE = "developer_mode"; + public static final String PREF_AUTO_VIDEO_FOCUS = "auto_video_focus"; + + // Phone management + public static final String PREF_PREFER_LAST_CONNECTED = "prefer_last_connected"; + public static final String PREF_SCREEN_PAIRED_PHONES = "screen_paired_phones"; + public static final String PREF_HEADER_PAIRED_PHONES = "paired_phones_header"; + public static final String PREF_NO_PAIRED_PHONES = "no_paired_phones"; + public static final String PREF_KNOWN_PHONES_COUNT = "aawireless_known_phones_count"; + public static final String PREF_KNOWN_PHONES_MAC = "aawireless_known_phones_mac_"; + public static final String PREF_KNOWN_PHONES_NAME = "aawireless_known_phones_name_"; + + // Intents + public static final String ACTION_PHONE_SWITCH = "aawireless_action_phone_switch"; + public static final String ACTION_PHONE_SORT = "aawireless_action_phone_sort"; + public static final String ACTION_PHONE_DELETE = "aawireless_action_phone_delete"; + public static final String EXTRA_PHONE_MAC = "phone_mac"; + public static final String EXTRA_PHONE_NEW_POSITION = "phone_position"; + + public AAWirelessPrefs(final SharedPreferences preferences, final GBDevice gbDevice) { + super(preferences, gbDevice); + } + + public String getCountry() { + return getString(DeviceSettingsPreferenceConst.PREF_COUNTRY, "US"); + } + + public boolean getAutoStandbyEnabled() { + return getBoolean(PREF_AUTO_STANDBY_ENABLED, false); + } + + public String getAutoStandbyDevice() { + return getString(PREF_AUTO_STANDBY_DEVICE, ""); + } + + public String getButtonModeSingleClick() { + return getString(PREF_BUTTON_MODE_SINGLE_CLICK, "none"); + } + + public String getButtonModeDoubleClick() { + return getString(PREF_BUTTON_MODE_DOUBLE_CLICK, "none"); + } + + public String getWiFiFrequency() { + return getString(DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY, "5"); + } + + public int getWiFiChannel() { + return "5".equals(getWiFiFrequency()) ? + getInt(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5, 0) : + getInt(DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4, 0); + } + + public boolean enableDongleMode() { + return getBoolean(PREF_DONGLE_MODE, false); + } + + public boolean enablePassthrough() { + return getBoolean(PREF_PASSTHROUGH, true); + } + + public String getAudioStutterFix() { + // off low high unlimited + return getString(PREF_AUDIO_STUTTER_FIX, "off"); + } + + public int getDpi() { + // [0-300] + return getInt(PREF_DPI, 0); + } + + public boolean disableMediaSink() { + return getBoolean(PREF_DISABLE_MEDIA_SINK, false); + } + + public boolean disableTtsSink() { + return getBoolean(PREF_DISABLE_TTS_SINK, false); + } + + public boolean removeTapRestriction() { + return getBoolean(PREF_REMOVE_TAP_RESTRICTION, false); + } + + public boolean enableVagCrashFix() { + return getBoolean(PREF_VAG_CRASH_FIX, false); + } + + public boolean enableStartFix() { + return getBoolean(PREF_START_FIX, false); + } + + public boolean enableDeveloperMode() { + return getBoolean(PREF_DEVELOPER_MODE, false); + } + + public boolean enableAutoVideoFocus() { + return getBoolean(PREF_AUTO_VIDEO_FOCUS, false); + } + + public boolean preferLastConnected() { + return getBoolean(PREF_PREFER_LAST_CONNECTED, false); + } + + public int getPairedPhoneCount() { + return getInt(PREF_KNOWN_PHONES_COUNT, 0); + } + + public String getPairedPhoneMac(final int position) { + return getString(PREF_KNOWN_PHONES_MAC + position, ""); + } + + public String getPairedPhoneName(final int position) { + return getString(PREF_KNOWN_PHONES_NAME + position, ""); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java new file mode 100644 index 0000000000..3f3d0cf85a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java @@ -0,0 +1,441 @@ +/* Copyright (C) 2025 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.service.devices.aawireless; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.widget.Toast; + +import androidx.core.content.ContextCompat; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceState; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.proto.aawireless.AAWirelessProto; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class AAWirelessSupport extends AbstractBTBRDeviceSupport { + private static final Logger LOG = LoggerFactory.getLogger(AAWirelessSupport.class); + + private static final UUID UUID_SERVICE_AAWIRELESS = UUID.fromString("b7b2ee43-c32a-41a6-9ee4-336c1365a1c4"); + + private static final int MAX_MTU = 2048; + + private static final String MAC_ADDR_PATTERN = "^([0-9A-F]{2}:){5}[0-9A-F]{2}$"; + + private static final short CMD_STATUS_REQUEST = 0x0001; + private static final short CMD_STATUS_RESPONSE = 0x0002; + private static final short CMD_SETTINGS_SET = 0x0003; + private static final short CMD_SETTINGS_ACK = 0x0004; + private static final short CMD_PHONE_DELETE_REQUEST = 0x000b; + private static final short CMD_PHONE_DELETE_ACK = 0x000c; + private static final short CMD_PHONE_POSITION_REQUEST = 0x000d; + private static final short CMD_PHONE_POSITION_ACK = 0x000e; + private static final short CMD_FACTORY_RESET_REQUEST = 0x000f; + private static final short CMD_FACTORY_RESET_ACK = 0x0010; + private static final short CMD_PHONE_SWITCH_REQUEST = 0x0011; + private static final short CMD_PHONE_SWITCH_ACK = 0x0012; + + private final ByteBuffer packetBuffer = ByteBuffer.allocate(MAX_MTU).order(ByteOrder.BIG_ENDIAN); + + private final BroadcastReceiver commandReceiver = new AAWirelessCommandReceiver(); + + public AAWirelessSupport() { + super(LOG); + addSupportedService(UUID_SERVICE_AAWIRELESS); + setBufferSize(MAX_MTU); + } + + @Override + public boolean useAutoConnect() { + return true; + } + + @Override + public AAWirelessPrefs getDevicePrefs() { + return new AAWirelessPrefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()), gbDevice); + } + + @Override + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + final IntentFilter commandFilter = new IntentFilter(); + commandFilter.addAction(AAWirelessPrefs.ACTION_PHONE_SWITCH); + commandFilter.addAction(AAWirelessPrefs.ACTION_PHONE_SORT); + commandFilter.addAction(AAWirelessPrefs.ACTION_PHONE_DELETE); + ContextCompat.registerReceiver(getContext(), commandReceiver, commandFilter, ContextCompat.RECEIVER_NOT_EXPORTED); + + sendCommand(builder, CMD_STATUS_REQUEST, new byte[0]); + return builder; + } + + @Override + public void dispose() { + try { + getContext().unregisterReceiver(commandReceiver); + } catch (final Exception e) { + LOG.warn("Failed to unregister receiver", e); + } + + super.dispose(); + } + + @Override + public void onSocketRead(final byte[] data) { + packetBuffer.put(data); + packetBuffer.flip(); + + while (packetBuffer.hasRemaining()) { + packetBuffer.mark(); + + if (packetBuffer.remaining() < 6) { + // not enough bytes for min packet + packetBuffer.reset(); + break; + } + + final int payloadLength = packetBuffer.getInt(); + final short command = packetBuffer.getShort(); + + if (packetBuffer.remaining() < payloadLength) { + // not enough bytes for payload + packetBuffer.reset(); + break; + } + + final byte[] payload = new byte[payloadLength]; + packetBuffer.get(payload); + + try { + handleCommand(command, payload); + } catch (final Exception e) { + LOG.error("Failed to handle command", e); + } + } + + packetBuffer.compact(); + } + + private void sendCommand(final TransactionBuilder builder, final short command, final byte[] payload) { + final ByteBuffer buf = ByteBuffer.allocate(payload.length + 6).order(ByteOrder.BIG_ENDIAN); + buf.putInt(payload.length); + buf.putShort(command); + buf.put(payload); + + builder.write(buf.array()); + } + + private void sendCommand(final String taskName, final short command, final byte[] payload) { + final TransactionBuilder builder = createTransactionBuilder(taskName); + sendCommand(builder, command, payload); + builder.queue(getQueue()); + } + + private void handleCommand(final short command, final byte[] payload) throws Exception { + switch (command) { + case CMD_STATUS_RESPONSE -> { + final AAWirelessProto.Status status = AAWirelessProto.Status.parseFrom(payload); + LOG.info("Got status = {}", status); + handleStatus(status); + } + case CMD_SETTINGS_ACK -> { + final AAWirelessProto.Ack ack = AAWirelessProto.Ack.parseFrom(payload); + LOG.info("Got settings ack, status={}, message={}", ack.getStatus(), ack.getMessage()); + if (ack.getStatus() != 0) { + GB.toast("AAW " + ack.getStatus() + ": " + ack.getMessage(), Toast.LENGTH_LONG, GB.WARN); + } + sendCommand("request status", CMD_STATUS_REQUEST, new byte[0]); + } + case CMD_PHONE_DELETE_ACK -> { + final AAWirelessProto.Ack ack = AAWirelessProto.Ack.parseFrom(payload); + LOG.info("Got Phone delete ack, status={}, message={}", ack.getStatus(), ack.getMessage()); + if (ack.getStatus() != 0) { + GB.toast("AAW " + ack.getStatus() + ": " + ack.getMessage(), Toast.LENGTH_LONG, GB.WARN); + } + sendCommand("request status", CMD_STATUS_REQUEST, new byte[0]); + } + case CMD_PHONE_POSITION_ACK -> { + final AAWirelessProto.Ack ack = AAWirelessProto.Ack.parseFrom(payload); + LOG.info("Got phone position ack, status={}, message={}", ack.getStatus(), ack.getMessage()); + if (ack.getStatus() != 0) { + GB.toast("AAW " + ack.getStatus() + ": " + ack.getMessage(), Toast.LENGTH_LONG, GB.WARN); + } + sendCommand("request status", CMD_STATUS_REQUEST, new byte[0]); + } + case CMD_FACTORY_RESET_ACK -> { + final AAWirelessProto.Ack ack = AAWirelessProto.Ack.parseFrom(payload); + LOG.info("Got factory reset ack, status={}, message={}", ack.getStatus(), ack.getMessage()); + if (ack.getStatus() != 0) { + GB.toast("AAW " + ack.getStatus() + ": " + ack.getMessage(), Toast.LENGTH_LONG, GB.WARN); + } + } + case CMD_PHONE_SWITCH_ACK -> { + final AAWirelessProto.Ack ack = AAWirelessProto.Ack.parseFrom(payload); + LOG.info("Got phone switch ack, status={}, message={}", ack.getStatus(), ack.getMessage()); + if (ack.getStatus() != 0) { + GB.toast("AAW " + ack.getStatus() + ": " + ack.getMessage(), Toast.LENGTH_LONG, GB.WARN); + } + } + default -> LOG.warn( + "Got unknown command {}, payload={}", + String.format("0x%04x", command), + GB.hexdump(payload) + ); + } + } + + private void handleStatus(final AAWirelessProto.Status status) { + // FW / HW + final GBDeviceEventVersionInfo versionInfoEvent = new GBDeviceEventVersionInfo(); + versionInfoEvent.hwVersion = status.getHardwareModel(); + versionInfoEvent.fwVersion = status.getFirmwareVersion(); + evaluateGBDeviceEvent(versionInfoEvent); + + // Mark as initialized + evaluateGBDeviceEvent(new GBDeviceEventUpdateDeviceState(GBDevice.State.INITIALIZED)); + + // Settings + final AAWirelessProto.Settings settings = status.getSettings(); + final GBDeviceEventUpdatePreferences preferencesEvent = new GBDeviceEventUpdatePreferences(); + preferencesEvent.withPreference(DeviceSettingsPreferenceConst.PREF_COUNTRY, settings.getCountry()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_AUTO_STANDBY_ENABLED, settings.getAutoStandbyEnabled()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_AUTO_STANDBY_DEVICE, settings.getAutoStandbyDevice()); + preferencesEvent.withPreference( + DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY, + settings.getWifiFrequency() == AAWirelessProto.WiFiFrequency.FREQ_2_4_GHZ ? "2.4" : "5" + ); + switch (settings.getWifiFrequency()) { + case FREQ_5_GHZ -> preferencesEvent.withPreference( + DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5, + String.valueOf(settings.getWifiChannel()) + ); + case FREQ_2_4_GHZ -> preferencesEvent.withPreference( + DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4, + String.valueOf(settings.getWifiChannel()) + ); + } + preferencesEvent.withPreference(AAWirelessPrefs.PREF_HAS_BUTTON, settings.hasButtonsConfig()); + if (settings.hasButtonsConfig()) { + preferencesEvent.withPreference(AAWirelessPrefs.PREF_BUTTON_MODE_SINGLE_CLICK, buttonModeToPref(settings.getButtonsConfig().getSingle())); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_BUTTON_MODE_DOUBLE_CLICK, buttonModeToPref(settings.getButtonsConfig().getDouble())); + } + preferencesEvent.withPreference(AAWirelessPrefs.PREF_DONGLE_MODE, settings.getDongleMode()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_PASSTHROUGH, settings.getPassthrough()); + final String audioStutterFixPrefValue; + if (settings.getAudioStutterFix() == 50) { + audioStutterFixPrefValue = AAWirelessPrefs.AUDIO_STUTTER_FIX_LOW; + } else if (settings.getAudioStutterFix() == 100) { + audioStutterFixPrefValue = AAWirelessPrefs.AUDIO_STUTTER_FIX_HIGH; + } else if (settings.getAudioStutterFix() == 4096) { + audioStutterFixPrefValue = AAWirelessPrefs.AUDIO_STUTTER_FIX_UNLIMITED; + } else { + audioStutterFixPrefValue = AAWirelessPrefs.AUDIO_STUTTER_FIX_OFF; + } + preferencesEvent.withPreference(AAWirelessPrefs.PREF_AUDIO_STUTTER_FIX, audioStutterFixPrefValue); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_DPI, String.valueOf(settings.getDpi())); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_DISABLE_MEDIA_SINK, settings.getDisableMediaSink()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_DISABLE_TTS_SINK, settings.getDisableTtsSink()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_REMOVE_TAP_RESTRICTION, settings.getRemoveTapRestriction()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_VAG_CRASH_FIX, settings.getVagCrashFix()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_START_FIX, settings.getStartFix()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_DEVELOPER_MODE, settings.getDeveloperMode()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_AUTO_VIDEO_FOCUS, settings.getAutoVideoFocus()); + + preferencesEvent.withPreference(AAWirelessPrefs.PREF_PREFER_LAST_CONNECTED, settings.getPreferLastConnectedPhone()); + + preferencesEvent.withPreference(AAWirelessPrefs.PREF_KNOWN_PHONES_COUNT, status.getPairedPhoneCount()); + for (int i = 0; i < status.getPairedPhoneCount(); i++) { + final AAWirelessProto.Phone phone = status.getPairedPhone(i); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_KNOWN_PHONES_MAC + i, phone.getMacAddress()); + preferencesEvent.withPreference(AAWirelessPrefs.PREF_KNOWN_PHONES_NAME + i, phone.getName()); + } + + evaluateGBDeviceEvent(preferencesEvent); + } + + private String buttonModeToPref(final AAWirelessProto.ButtonMode buttonMode) { + if (buttonMode.hasNextPhone()) { + return AAWirelessPrefs.BUTTON_MODE_NEXT_PHONE; + } else if (buttonMode.hasSelectPhone()) { + return buttonMode.getSelectPhone().getMacAddress(); + } else if (buttonMode.hasStandbyOnOff()) { + return AAWirelessPrefs.BUTTON_MODE_STANDBY_ON_OFF; + } else { + return AAWirelessPrefs.BUTTON_MODE_NONE; + } + } + + private AAWirelessProto.ButtonMode prefToButtonMode(final String prefValue) { + final AAWirelessProto.ButtonMode.Builder builder = AAWirelessProto.ButtonMode.newBuilder(); + switch (prefValue) { + case AAWirelessPrefs.BUTTON_MODE_NEXT_PHONE: + builder.setNextPhone(""); + break; + case AAWirelessPrefs.BUTTON_MODE_STANDBY_ON_OFF: + builder.setStandbyOnOff(""); + break; + case AAWirelessPrefs.BUTTON_MODE_NONE: + break; + default: + if (prefValue.matches(MAC_ADDR_PATTERN)) { + builder.setSelectPhone(AAWirelessProto.Phone.newBuilder().setMacAddress(prefValue)); + } else { + LOG.warn("Unexpected button mode preference value {}", prefValue); + } + } + + return builder.build(); + } + + @Override + public void onSendConfiguration(final String config) { + switch (config) { + case DeviceSettingsPreferenceConst.PREF_COUNTRY: + case AAWirelessPrefs.PREF_AUTO_STANDBY_ENABLED: + case AAWirelessPrefs.PREF_AUTO_STANDBY_DEVICE: + case DeviceSettingsPreferenceConst.PREF_WIFI_FREQUENCY: + case DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_2_4: + case DeviceSettingsPreferenceConst.PREF_WIFI_CHANNEL_5: + case AAWirelessPrefs.PREF_BUTTON_MODE_SINGLE_CLICK: + case AAWirelessPrefs.PREF_BUTTON_MODE_DOUBLE_CLICK: + case AAWirelessPrefs.PREF_DONGLE_MODE: + case AAWirelessPrefs.PREF_PASSTHROUGH: + case AAWirelessPrefs.PREF_AUDIO_STUTTER_FIX: + case AAWirelessPrefs.PREF_DPI: + case AAWirelessPrefs.PREF_DISABLE_MEDIA_SINK: + case AAWirelessPrefs.PREF_DISABLE_TTS_SINK: + case AAWirelessPrefs.PREF_REMOVE_TAP_RESTRICTION: + case AAWirelessPrefs.PREF_VAG_CRASH_FIX: + case AAWirelessPrefs.PREF_START_FIX: + case AAWirelessPrefs.PREF_DEVELOPER_MODE: + case AAWirelessPrefs.PREF_AUTO_VIDEO_FOCUS: + sendSettings(); + return; + } + + super.onSendConfiguration(config); + } + + private void sendSettings() { + final AAWirelessPrefs prefs = getDevicePrefs(); + + final AAWirelessProto.WiFiFrequency wiFiFrequency = switch (prefs.getWiFiFrequency()) { + case "5" -> AAWirelessProto.WiFiFrequency.FREQ_5_GHZ; + case "2.4" -> AAWirelessProto.WiFiFrequency.FREQ_2_4_GHZ; + default -> throw new IllegalArgumentException( + "Unknown wifi frequency " + prefs.getWiFiFrequency() + ); + }; + + final long audioStutterFix = switch (prefs.getAudioStutterFix()) { + case "unlimited" -> 4096; + case "high" -> 100; + case "low" -> 50; + case "off" -> -1; // 18446744073709551615 in uint64 + default -> throw new IllegalArgumentException( + "Unknown audio stutter fix " + prefs.getAudioStutterFix() + ); + }; + + final AAWirelessProto.ButtonsConfig buttonsConfig = AAWirelessProto.ButtonsConfig.newBuilder() + .setSingle(prefToButtonMode(prefs.getButtonModeSingleClick())) + .setDouble(prefToButtonMode(prefs.getButtonModeDoubleClick())) + .build(); + + final AAWirelessProto.Settings.Builder builder = AAWirelessProto.Settings.newBuilder() + .setPassthrough(prefs.enablePassthrough()) + .setVagCrashFix(prefs.enableVagCrashFix()) + .setDpi(prefs.getDpi()) + .setRemoveTapRestriction(prefs.removeTapRestriction()) + .setDeveloperMode(prefs.enableDeveloperMode()) + .setDisableMediaSink(prefs.disableMediaSink()) + .setWifiFrequency(wiFiFrequency) + .setAutoVideoFocus(prefs.enableAutoVideoFocus()) + .setWifiChannel(prefs.getWiFiChannel()) + .setCountry(prefs.getCountry()) + .setPreferLastConnectedPhone(prefs.preferLastConnected()) + .setDisableTtsSink(prefs.disableTtsSink()) + .setDongleMode(prefs.enableDongleMode()) + .setAutoStandbyDevice(prefs.getAutoStandbyDevice()) + .setStartFix(prefs.enableStartFix()) + .setAudioStutterFix(audioStutterFix) + .setButtonsConfig(buttonsConfig) + .setAutoStandbyEnabled(prefs.getAutoStandbyEnabled()); + + sendCommand("send settings", CMD_SETTINGS_SET, builder.build().toByteArray()); + } + + @Override + public void onReset(final int flags) { + if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) != 0) { + sendCommand("factory reset", CMD_FACTORY_RESET_REQUEST, new byte[0]); + } + } + + private class AAWirelessCommandReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (action == null) { + return; + } + + final String mac = intent.getStringExtra(AAWirelessPrefs.EXTRA_PHONE_MAC); + + switch (action) { + case AAWirelessPrefs.ACTION_PHONE_SWITCH: { + LOG.debug("Switch phone to {}", mac); + final AAWirelessProto.Phone payload = AAWirelessProto.Phone.newBuilder().setMacAddress(mac).build(); + sendCommand("switch phone", CMD_PHONE_SWITCH_REQUEST, payload.toByteArray()); + return; + } + case AAWirelessPrefs.ACTION_PHONE_SORT: { + final int newPosition = intent.getIntExtra(AAWirelessPrefs.EXTRA_PHONE_NEW_POSITION, 0); + LOG.debug("Sort phone {} to {}", mac, newPosition); + final AAWirelessProto.PhonePosition payload = AAWirelessProto.PhonePosition.newBuilder() + .setMacAddress(mac) + .setPosition(newPosition) + .build(); + sendCommand("set phone position", CMD_PHONE_POSITION_REQUEST, payload.toByteArray()); + return; + } + case AAWirelessPrefs.ACTION_PHONE_DELETE: { + final AAWirelessProto.Phone payload = AAWirelessProto.Phone.newBuilder().setMacAddress(mac).build(); + LOG.debug("Delete phone {}", mac); + sendCommand("delete phone", CMD_PHONE_DELETE_REQUEST, payload.toByteArray()); + return; + } + } + + LOG.warn("Unknown action {}", action); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/earfun/EarFunSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/earfun/EarFunSettingsCustomizer.java index 02740e5096..876bd44ee2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/earfun/EarFunSettingsCustomizer.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/earfun/EarFunSettingsCustomizer.java @@ -8,12 +8,10 @@ import android.os.Parcel; import android.text.InputFilter; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.SeekBarPreference; -import androidx.preference.SwitchPreferenceCompat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +24,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -39,7 +38,7 @@ public class EarFunSettingsCustomizer implements DeviceSpecificSettingsCustomize this.device = device; } - public static final Creator CREATOR = new Creator() { + public static final Creator CREATOR = new Creator<>() { @Override public EarFunSettingsCustomizer createFromParcel(final Parcel in) { final GBDevice device = in.readParcelable(EarFunSettingsCustomizer.class.getClassLoader()); @@ -60,7 +59,7 @@ public class EarFunSettingsCustomizer implements DeviceSpecificSettingsCustomize @Override public void customizeSettings(DeviceSpecificSettingsHandler handler, Prefs prefs, String rootKey) { - addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_DEVICE_NAME, R.string.prefs_device_name, R.string.earfun_change_device_name_confirm_message); + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_DEVICE_NAME, R.string.earfun_change_device_name_confirm_message); handler.addPreferenceHandlerFor(PREF_EARFUN_AMBIENT_SOUND_CONTROL); handler.addPreferenceHandlerFor(PREF_EARFUN_TRANSPARENCY_MODE); handler.addPreferenceHandlerFor(PREF_EARFUN_ANC_MODE); @@ -87,12 +86,12 @@ public class EarFunSettingsCustomizer implements DeviceSpecificSettingsCustomize handler.addPreferenceHandlerFor(PREF_EARFUN_EQUALIZER_BAND_16000); handler.addPreferenceHandlerFor(PREF_EARFUN_IN_EAR_DETECTION_MODE); handler.addPreferenceHandlerFor(PREF_EARFUN_TOUCH_MODE); - addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_CONNECT_TWO_DEVICES_MODE, R.string.earfun_connect_two_devices, R.string.earfun_reboot_confirm_message); - addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_ADVANCED_AUDIO_MODE, R.string.earfun_advanced_audio_mode, R.string.earfun_reboot_confirm_message); - addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_MICROPHONE_MODE, R.string.earfun_microphone_mode, R.string.earfun_reboot_confirm_message); + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_CONNECT_TWO_DEVICES_MODE, R.string.earfun_reboot_confirm_message); + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_ADVANCED_AUDIO_MODE, R.string.earfun_reboot_confirm_message); + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_MICROPHONE_MODE, R.string.earfun_reboot_confirm_message); handler.addPreferenceHandlerFor(PREF_EARFUN_FIND_DEVICE); handler.addPreferenceHandlerFor(PREF_EARFUN_VOICE_PROMPT_VOLUME); - addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_AUDIO_CODEC, R.string.audio_codec, R.string.earfun_reboot_confirm_message); + DeviceSettingsUtils.addConfirmablePreferenceHandlerFor(handler, PREF_EARFUN_AUDIO_CODEC, R.string.earfun_reboot_confirm_message); EditTextPreference editTextDeviceName = handler.findPreference(PREF_EARFUN_DEVICE_NAME); if (editTextDeviceName != null) { @@ -103,42 +102,6 @@ public class EarFunSettingsCustomizer implements DeviceSpecificSettingsCustomize } } - protected void addConfirmablePreferenceHandlerFor(DeviceSpecificSettingsHandler handler, final String preferenceKey, int alertPreferenceNAme, int alertMessage) { - Preference pref = handler.findPreference(preferenceKey); - Context context = handler.getContext(); - if (pref != null) { - pref.setOnPreferenceChangeListener((preference, newValue) -> { - String preferenceName = context.getString(alertPreferenceNAme); - String title = context.getString(R.string.earfun_change_confirm_title, preferenceName); - String message = context.getString(alertMessage); - - new AlertDialog.Builder(context) - .setTitle(title) - .setMessage(message) - .setPositiveButton(handler.getContext().getString(R.string.ok), (dialog, which) -> { - if (pref instanceof EditTextPreference) { - ((EditTextPreference) pref).setText(newValue.toString()); - } - if (pref instanceof ListPreference) { - ((ListPreference) pref).setValue(newValue.toString()); - } - if (pref instanceof SwitchPreferenceCompat) { - ((SwitchPreferenceCompat) pref).setChecked((Boolean) newValue); - } else { - LOG.error("unsupported preference type for confirmable handler: {}", pref); - } - handler.notifyPreferenceChanged(preferenceKey); - }) - .setNegativeButton(handler.getContext().getString(R.string.Cancel), (dialog, which) -> { - dialog.dismiss(); - }) - .create() - .show(); - return false; - }); - } - } - protected void initializeEqualizerPresetListPreference(DeviceSpecificSettingsHandler handler, Equalizer.EqualizerPreset[] equalizerPresets) { ListPreference equalizerPresetListPreference = handler.findPreference(PREF_EARFUN_EQUALIZER_PRESET); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/CountryCodeTextWatcher.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/CountryCodeTextWatcher.java new file mode 100644 index 0000000000..139621db42 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/CountryCodeTextWatcher.java @@ -0,0 +1,63 @@ +/* Copyright (C) 2025 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.util.preferences; + +import android.text.Editable; +import android.text.TextWatcher; +import android.widget.EditText; + +public class CountryCodeTextWatcher implements TextWatcher { + private final EditText editText; + + public CountryCodeTextWatcher(final EditText editText) { + this.editText = editText; + } + + @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.removeTextChangedListener(this); + + final StringBuilder filtered = new StringBuilder(); + for (int i = 0; i < editable.length(); i++) { + final char c = Character.toUpperCase(editable.charAt(i)); + if (c >= 'A' && c <= 'Z') { + filtered.append(c); + } + } + + String newText = filtered.toString(); + if (newText.length() > 2) { + newText = newText.substring(0, 2); + } + + if (!newText.equals(editable.toString())) { + editText.setText(newText); + editText.setSelection(newText.length()); + } + + editText.addTextChangedListener(this); + editText.getRootView().findViewById(android.R.id.button1).setEnabled(editText.getText().length() == 2); + } +} diff --git a/app/src/main/proto/aawireless.proto b/app/src/main/proto/aawireless.proto new file mode 100644 index 0000000000..b9eb49de2e --- /dev/null +++ b/app/src/main/proto/aawireless.proto @@ -0,0 +1,93 @@ +syntax = "proto3"; + +package aawireless; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.aawireless"; +option java_outer_classname = "AAWirelessProto"; + +message Status { + optional string macAddress = 1; + optional string firmwareVersion = 2; + optional Settings settings = 3; + optional CarInfo carInfo = 4; + optional string hardwareModel = 5; + repeated Phone pairedPhone = 11; +} + +message Phone { + optional string macAddress = 1; + optional string name = 2; + optional uint32 position = 3; // 0,1,2... + optional bool current = 4; // 0/1 - current device? (might not be the connected one) + optional uint32 unk5 = 5; // 0 +} + +message PhonePosition { + optional string macAddress = 1; + optional uint32 position = 2; +} + +message CarInfo { +} + +enum WiFiFrequency { + FREQ_5_GHZ = 0; + FREQ_2_4_GHZ = 1; +} + +message Settings { + optional uint32 unk1 = 1; // 0 + optional uint32 unk2 = 2; // 0 + optional bool passthrough = 3; + optional bool vagCrashFix = 4; + optional uint32 dpi = 5; // [0,300] + optional bool removeTapRestriction = 6; + optional bool developerMode = 7; + optional bool disableMediaSink = 8; + optional uint32 unk9 = 9; // 0 + optional WiFiFrequency wifiFrequency = 10; + optional bool autoVideoFocus = 11; + optional uint32 unk12 = 12; // 20000 + optional uint32 unk13 = 13; // 0 + optional string unk14 = 14; // "" + optional string unk15 = 15; // "" + optional uint32 wifiChannel = 16; + optional uint32 unk17 = 17; // 0 + optional string country = 18; // 2-letter + optional bool preferLastConnectedPhone = 19; + optional bool disableTtsSink = 20; + optional bool dongleMode = 21; + optional string autoStandbyDevice = 22; // mac address + optional uint32 unk23 = 23; // 0 + optional uint32 unk24 = 24; // 0 + optional bool startFix = 25; + // 26 is some byte array? + optional uint32 unk27 = 27; // 0 + optional uint32 unk28 = 28; // 0 + optional uint32 unk29 = 29; // 0 + optional uint32 unk30 = 30; // 0 + optional uint64 audioStutterFix = 31; // off 18446744073709551615, low 50, high 100, unlimited 4096 + optional uint32 unk32 = 32; // 0 + optional uint32 unk33 = 33; // 1 + optional uint32 unk34 = 34; // 0 + optional ButtonsConfig buttonsConfig = 35; + optional bool autoStandbyEnabled = 37; +} + +message ButtonsConfig { + optional ButtonMode single = 1; + optional ButtonMode double = 2; + optional ButtonMode unk4 = 4; // { 1: "" } +} + +message ButtonMode { + // only the desired one is set, nothing set for no action + optional string nextPhone = 2; // "" + optional Phone selectPhone = 3; // only mac address + optional string standbyOnOff = 4; // "" +} + +message Ack { + optional uint32 status = 1; // 0 for success? + optional string message = 2; // "" - error msg? +} diff --git a/app/src/main/res/drawable/ic_angle.xml b/app/src/main/res/drawable/ic_angle.xml index c5a2dd20f8..d00917b20e 100644 --- a/app/src/main/res/drawable/ic_angle.xml +++ b/app/src/main/res/drawable/ic_angle.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml new file mode 100644 index 0000000000..cd5aa2e1b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_downward.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_arrows_up_down.xml b/app/src/main/res/drawable/ic_arrows_up_down.xml index 2a4f283bc1..bd3acb71fb 100644 --- a/app/src/main/res/drawable/ic_arrows_up_down.xml +++ b/app/src/main/res/drawable/ic_arrows_up_down.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_frequency.xml b/app/src/main/res/drawable/ic_frequency.xml new file mode 100644 index 0000000000..32e01055e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_frequency.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_smartphone.xml b/app/src/main/res/drawable/ic_smartphone.xml new file mode 100644 index 0000000000..0d034f1293 --- /dev/null +++ b/app/src/main/res/drawable/ic_smartphone.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_usb_dongle.xml b/app/src/main/res/drawable/ic_usb_dongle.xml new file mode 100644 index 0000000000..508d8a0cf0 --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_dongle.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/simple_list_item_with_icon.xml b/app/src/main/res/layout/simple_list_item_with_icon.xml new file mode 100644 index 0000000000..23ae609b51 --- /dev/null +++ b/app/src/main/res/layout/simple_list_item_with_icon.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 34dbd82e69..492fedf968 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -230,6 +230,95 @@ buttons_on_right + + @string/wifi_frequency_2_4_ghz + @string/wifi_frequency_5_ghz + + + 2.4 + 5 + + + + @string/off + @string/pref_aawireless_audio_stutter_fix_low + @string/pref_aawireless_audio_stutter_fix_high + @string/pref_aawireless_audio_stutter_fix_unlimited + + + off + low + high + unlimited + + + + @string/none + @string/switch_to_next_phone + @string/toggle_standby_on_off + + + none + next_phone + standby_on_off + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + + + 32 + 36 + 40 + 44 + 48 + 52 + 56 + 60 + 64 + 68 + 72 + 76 + 80 + 84 + 88 + 92 + 96 + 100 + 104 + 108 + 112 + 116 + 120 + 124 + 128 + 132 + 136 + 140 + 144 + 149 + 153 + 157 + 161 + 165 + 169 + 173 + 177 + + @string/wearmode_band @string/wearmode_pebble diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e770d38335..d918768886 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2008,6 +2008,7 @@ Redmi Watch 4 Redmi Watch 5 Active Redmi Watch 5 Lite + AAWireless Colmi R02 Colmi R03 Colmi R06 @@ -4009,5 +4010,51 @@ %1$s %2$s (%3$s: %4$s) Left Lens Right Lens - + After toggling dongle mode, you will need to pair the device again. Please wait at least 30 seconds for the setting to be applied. + Next phone + Standby on/off + Advanced settings + Country + Developer mode + Double click + Screen DPI + Factory reset + Long press (10 sec.) + Long press (2 sec.) + There are no paired phones + Paired phones + Pairing mode + High + Low + Override the audio buffer to fix audio stutter + Audio stutter fix + Unlimited + Auto standby enabled + Auto standby device + Automatically enable standby mode when the selected device is not connected to the phone. Enable this if the USB port in your car has power when the car is off. + Automatically open Android Auto when connected, skipping the manual screen prompt. Might not work on all cars. + Auto-start Android Auto + Enable for app development + Allow media playback through a different Bluetooth device + Disable media sink + Allow speech instructions to through a different Bluetooth device + Disable TTS sink + Use the native dongle logic for Android Auto. This may help with bluetooth hands-free connectivity issues. When enabled, manual phone switching is not possible. + Dongle mode + Set to 0 to use the car\'s default value + Pass data directly, without any processing + Passthrough + Prefer last connected phone + Allow over 6 taps in succession. May impact GPS accuracy. + Remove tap restriction + Workaround - enable this if the LED is solid blue, but Android Auto is not starting + Start fix + Fix crash on VAG Technisat units during song skip/pause. Do not enable on other cars, may cause glitches. + VAG crash fix + Single click + Switch to %1$s + Wi-Fi Channel + 2.4 GHz + 5 GHz + Wi-Fi Frequency diff --git a/app/src/main/res/xml/devicesettings_aawireless_advanced.xml b/app/src/main/res/xml/devicesettings_aawireless_advanced.xml new file mode 100644 index 0000000000..268a27ad87 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_aawireless_advanced.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_aawireless_auto_standby.xml b/app/src/main/res/xml/devicesettings_aawireless_auto_standby.xml new file mode 100644 index 0000000000..3525be66e6 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_aawireless_auto_standby.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_aawireless_button.xml b/app/src/main/res/xml/devicesettings_aawireless_button.xml new file mode 100644 index 0000000000..e1891e706a --- /dev/null +++ b/app/src/main/res/xml/devicesettings_aawireless_button.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_aawireless_paired_phones.xml b/app/src/main/res/xml/devicesettings_aawireless_paired_phones.xml new file mode 100644 index 0000000000..f0f1c66fca --- /dev/null +++ b/app/src/main/res/xml/devicesettings_aawireless_paired_phones.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_buttonactions.xml b/app/src/main/res/xml/devicesettings_buttonactions.xml index 7eb826aa64..f18468d773 100644 --- a/app/src/main/res/xml/devicesettings_buttonactions.xml +++ b/app/src/main/res/xml/devicesettings_buttonactions.xml @@ -7,11 +7,6 @@ android:summary="@string/mi2_prefs_button_actions_summary" android:title="@string/mi2_prefs_button_actions"> - - - - public static final String PREF_BUTTON_ACTION_PRESS_DELAY = "button_action_press_count_delay"; - + + + + + diff --git a/app/src/main/res/xml/devicesettings_root_advanced_settings.xml b/app/src/main/res/xml/devicesettings_root_advanced_settings.xml new file mode 100644 index 0000000000..9a9fd92815 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_root_advanced_settings.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/xml/devicesettings_wifi_frequency_channel.xml b/app/src/main/res/xml/devicesettings_wifi_frequency_channel.xml new file mode 100644 index 0000000000..4a1ec0bf9d --- /dev/null +++ b/app/src/main/res/xml/devicesettings_wifi_frequency_channel.xml @@ -0,0 +1,28 @@ + + + + + + + +