500 lines
19 KiB
Java
500 lines
19 KiB
Java
/* Copyright (C) 2023-2024 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 <https://www.gnu.org/licenses/>. */
|
|
package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
|
|
|
|
import android.text.TextUtils;
|
|
|
|
import androidx.annotation.Nullable;
|
|
|
|
import com.google.protobuf.InvalidProtocolBufferException;
|
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collection;
|
|
import java.util.Collections;
|
|
import java.util.HashSet;
|
|
import java.util.LinkedList;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
|
import nodomain.freeyourgadget.gadgetbridge.R;
|
|
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPartSubtype;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen;
|
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType;
|
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
|
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
|
|
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
|
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
|
|
|
public class XiaomiWidgetManager implements WidgetManager {
|
|
private static final Logger LOG = LoggerFactory.getLogger(XiaomiWidgetManager.class);
|
|
|
|
private final GBDevice device;
|
|
|
|
public XiaomiWidgetManager(final GBDevice device) {
|
|
this.device = device;
|
|
}
|
|
|
|
@Override
|
|
public List<WidgetLayout> getSupportedWidgetLayouts() {
|
|
final List<WidgetLayout> layouts = new ArrayList<>();
|
|
|
|
final XiaomiProto.WidgetScreens widgetScreens = getRawWidgetScreens();
|
|
if (!widgetScreens.hasWidgetsCapabilities() || !widgetScreens.getWidgetsCapabilities().hasSupportedLayoutStyles()) {
|
|
return Collections.emptyList();
|
|
}
|
|
|
|
final int supportedBitmap = getRawWidgetScreens().getWidgetsCapabilities().getSupportedLayoutStyles();
|
|
|
|
// highest known layout style is 0x4000 (1 << 14)
|
|
for (int i = 0; i < 15; i++) {
|
|
final int layoutStyleId = 1 << i;
|
|
if ((supportedBitmap & layoutStyleId) != 0) {
|
|
layouts.add(fromRawLayout(layoutStyleId));
|
|
}
|
|
}
|
|
|
|
return layouts;
|
|
}
|
|
|
|
private static Collection<WidgetPartSubtype> convertWorkoutTypesToPartSubtypes(final Collection<XiaomiWorkoutType> workoutTypes) {
|
|
final List<WidgetPartSubtype> subtypes = new ArrayList<>(workoutTypes.size());
|
|
|
|
// convert workout types to subtypes
|
|
for (final XiaomiWorkoutType workoutType : workoutTypes) {
|
|
subtypes.add(new WidgetPartSubtype(
|
|
String.valueOf(workoutType.getCode()),
|
|
workoutType.getName()
|
|
));
|
|
}
|
|
|
|
// sort by name before returning
|
|
Collections.sort(subtypes, (it, other) -> it.getName().compareToIgnoreCase(other.getName()));
|
|
|
|
return subtypes;
|
|
}
|
|
|
|
@Override
|
|
public List<WidgetPart> getSupportedWidgetParts(final WidgetType targetWidgetType) {
|
|
final List<WidgetPart> parts = new LinkedList<>();
|
|
|
|
final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts();
|
|
|
|
final Set<String> seenNames = new HashSet<>();
|
|
final Set<String> duplicatedNames = new HashSet<>();
|
|
|
|
// get supported workout types and convert to subtypes for workout widgets
|
|
final Collection<WidgetPartSubtype> subtypes = convertWorkoutTypesToPartSubtypes(XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice()));
|
|
|
|
for (final XiaomiProto.WidgetPart widgetPart : rawWidgetParts.getWidgetPartList()) {
|
|
final WidgetPart convertedPart = fromRawWidgetPart(widgetPart, subtypes);
|
|
|
|
if (convertedPart == null) {
|
|
continue;
|
|
}
|
|
|
|
if (!convertedPart.getType().equals(targetWidgetType)) {
|
|
continue;
|
|
}
|
|
|
|
final String convertedPartName = convertedPart.getName();
|
|
if (seenNames.contains(convertedPartName)) {
|
|
duplicatedNames.add(convertedPartName);
|
|
} else {
|
|
seenNames.add(convertedPartName);
|
|
}
|
|
|
|
parts.add(convertedPart);
|
|
seenNames.add(convertedPart.getName());
|
|
}
|
|
|
|
// Ensure that all names are unique
|
|
for (final WidgetPart part : parts) {
|
|
if (duplicatedNames.contains(part.getFullName())) {
|
|
part.setName(String.format(Locale.ROOT, "%s (%s)", part.getName(), part.getId()));
|
|
}
|
|
}
|
|
|
|
Collections.sort(parts, (it, other) -> it.getName().compareToIgnoreCase(other.getName()));
|
|
return parts;
|
|
}
|
|
|
|
private WidgetPart fromRawWidgetPart(final XiaomiProto.WidgetPart widgetPart, final Collection<WidgetPartSubtype> subtypes) {
|
|
final WidgetType type = fromRawWidgetType(widgetPart.getType());
|
|
|
|
if (type == null) {
|
|
LOG.warn("Unknown widget type {}", widgetPart.getType());
|
|
return null;
|
|
}
|
|
|
|
final String stringifiedId = String.valueOf(widgetPart.getId());
|
|
final WidgetPart convertedPart = new WidgetPart(
|
|
stringifiedId,
|
|
GBApplication.getContext().getString(R.string.widget_name_untitled, stringifiedId),
|
|
type
|
|
);
|
|
|
|
if (!TextUtils.isEmpty(widgetPart.getTitle())) {
|
|
convertedPart.setName(widgetPart.getTitle());
|
|
} else {
|
|
// some models do not provide the name of the widget in the screens list, resolve it here
|
|
final XiaomiProto.WidgetPart resolvedPart = findRawPart(widgetPart.getType(), widgetPart.getId());
|
|
if (resolvedPart != null) {
|
|
convertedPart.setName(resolvedPart.getTitle());
|
|
}
|
|
}
|
|
|
|
if (widgetPart.getFunction() == 16) {
|
|
if (StringUtils.isBlank(convertedPart.getName())) {
|
|
convertedPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout));
|
|
}
|
|
|
|
if (subtypes != null) {
|
|
convertedPart.getSupportedSubtypes().addAll(subtypes);
|
|
|
|
if (widgetPart.getSubType() != 0) {
|
|
final String widgetSubtype = String.valueOf(widgetPart.getSubType());
|
|
|
|
for (final WidgetPartSubtype availableSubtype : subtypes) {
|
|
if (availableSubtype.getId().equals(widgetSubtype)) {
|
|
convertedPart.setSubtype(availableSubtype);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((widgetPart.getId() & 256) != 0) {
|
|
convertedPart.setName(GBApplication.getContext().getString(R.string.widget_name_colored_tile, convertedPart.getName()));
|
|
}
|
|
|
|
return convertedPart;
|
|
}
|
|
|
|
@Override
|
|
public List<WidgetScreen> getWidgetScreens() {
|
|
final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
|
|
|
|
final List<WidgetScreen> convertedScreens = new ArrayList<>(rawWidgetScreens.getWidgetScreenCount());
|
|
final Collection<WidgetPartSubtype> workoutTypes = convertWorkoutTypesToPartSubtypes(XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice()));
|
|
|
|
for (final XiaomiProto.WidgetScreen rawScreen : rawWidgetScreens.getWidgetScreenList()) {
|
|
final WidgetLayout layout = fromRawLayout(rawScreen.getLayout());
|
|
|
|
final List<WidgetPart> convertedParts = new ArrayList<>(rawScreen.getWidgetPartCount());
|
|
|
|
for (final XiaomiProto.WidgetPart rawPart : rawScreen.getWidgetPartList()) {
|
|
final WidgetPart convertedPart = fromRawWidgetPart(rawPart, workoutTypes);
|
|
|
|
if (convertedPart == null) {
|
|
LOG.warn("Widget cannot be converted, result was null for following raw widget: {}", rawPart);
|
|
continue;
|
|
}
|
|
|
|
convertedParts.add(convertedPart);
|
|
}
|
|
|
|
convertedScreens.add(new WidgetScreen(
|
|
String.valueOf(rawScreen.getId()),
|
|
layout,
|
|
convertedParts
|
|
));
|
|
}
|
|
|
|
return convertedScreens;
|
|
}
|
|
|
|
@Override
|
|
public GBDevice getDevice() {
|
|
return device;
|
|
}
|
|
|
|
@Override
|
|
public int getMinScreens() {
|
|
return getRawWidgetScreens().getWidgetsCapabilities().getMinWidgets();
|
|
}
|
|
|
|
@Override
|
|
public int getMaxScreens() {
|
|
return getRawWidgetScreens().getWidgetsCapabilities().getMaxWidgets();
|
|
}
|
|
|
|
@Override
|
|
public void saveScreen(final WidgetScreen widgetScreen) {
|
|
final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
|
|
|
|
final int layoutNum = toRawLayout(widgetScreen.getLayout());
|
|
if (layoutNum == -1) {
|
|
return;
|
|
}
|
|
|
|
XiaomiProto.WidgetScreen.Builder rawScreen = null;
|
|
if (widgetScreen.getId() == null) {
|
|
// new screen
|
|
rawScreen = XiaomiProto.WidgetScreen.newBuilder()
|
|
.setId(rawWidgetScreens.getWidgetScreenCount() + 1); // ids start at 1
|
|
} else {
|
|
for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
|
|
if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) {
|
|
rawScreen = XiaomiProto.WidgetScreen.newBuilder(screen);
|
|
break;
|
|
}
|
|
|
|
LOG.warn("Failed to find original screen for {}", widgetScreen.getId());
|
|
}
|
|
|
|
if (rawScreen == null) {
|
|
rawScreen = XiaomiProto.WidgetScreen.newBuilder()
|
|
.setId(rawWidgetScreens.getWidgetScreenCount() + 1);
|
|
}
|
|
}
|
|
|
|
rawScreen.setLayout(layoutNum);
|
|
rawScreen.clearWidgetPart();
|
|
|
|
final Collection<XiaomiWorkoutType> workoutTypes = XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice());
|
|
|
|
for (final WidgetPart newPart : widgetScreen.getParts()) {
|
|
// Find the existing raw part
|
|
final XiaomiProto.WidgetPart knownRawPart = findRawPart(
|
|
toRawWidgetType(newPart.getType()),
|
|
Integer.parseInt(Objects.requireNonNull(newPart.getId()))
|
|
);
|
|
|
|
final XiaomiProto.WidgetPart.Builder newRawPartBuilder = XiaomiProto.WidgetPart.newBuilder(knownRawPart);
|
|
|
|
// TODO only support subtypes on widget with type 16
|
|
if (newPart.getSubtype() != null) {
|
|
try {
|
|
final int rawSubtype = Integer.parseInt(newPart.getSubtype().getId());
|
|
|
|
// Get the workout type as subtype
|
|
for (final XiaomiWorkoutType workoutType : workoutTypes) {
|
|
if (rawSubtype == workoutType.getCode()) {
|
|
newRawPartBuilder.setSubType(workoutType.getCode());
|
|
break;
|
|
}
|
|
}
|
|
} catch (final NumberFormatException ex) {
|
|
LOG.error("Failed to convert workout type {} to a number, defaulting to 1", newPart.getSubtype());
|
|
newRawPartBuilder.setSubType(1);
|
|
}
|
|
}
|
|
|
|
rawScreen.addWidgetPart(newRawPartBuilder);
|
|
}
|
|
|
|
final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens);
|
|
if (rawScreen.getId() == rawWidgetScreens.getWidgetScreenCount() + 1) {
|
|
// Append at the end
|
|
builder.addWidgetScreen(rawScreen);
|
|
} else {
|
|
// Replace existing
|
|
builder.clearWidgetScreen();
|
|
|
|
for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
|
|
if (screen.getId() == rawScreen.getId()) {
|
|
builder.addWidgetScreen(rawScreen);
|
|
} else {
|
|
builder.addWidgetScreen(screen);
|
|
}
|
|
}
|
|
}
|
|
|
|
builder.setIsFullList(1);
|
|
|
|
getPrefs().getPreferences().edit()
|
|
.putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray()))
|
|
.apply();
|
|
}
|
|
|
|
@Override
|
|
public void deleteScreen(final WidgetScreen widgetScreen) {
|
|
if (widgetScreen.getId() == null) {
|
|
LOG.warn("Can't delete screen without id");
|
|
return;
|
|
}
|
|
|
|
final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
|
|
|
|
final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens)
|
|
.clearWidgetScreen();
|
|
|
|
for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
|
|
if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) {
|
|
continue;
|
|
}
|
|
|
|
builder.addWidgetScreen(screen);
|
|
}
|
|
|
|
getPrefs().getPreferences().edit()
|
|
.putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray()))
|
|
.apply();
|
|
}
|
|
|
|
@Override
|
|
public void sendToDevice() {
|
|
GBApplication.deviceService(getDevice()).onSendConfiguration(DeviceSettingsPreferenceConst.PREF_WIDGETS);
|
|
}
|
|
|
|
private Prefs getPrefs() {
|
|
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()));
|
|
}
|
|
|
|
@Nullable
|
|
private WidgetType fromRawWidgetType(final int rawType) {
|
|
switch (rawType) {
|
|
case 1:
|
|
return WidgetType.SMALL;
|
|
case 2:
|
|
return WidgetType.WIDE;
|
|
case 3:
|
|
return WidgetType.TALL;
|
|
case 4:
|
|
return WidgetType.LARGE;
|
|
default:
|
|
LOG.warn("Unknown widget type {}", rawType);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private int toRawWidgetType(final WidgetType widgetType) {
|
|
switch (widgetType) {
|
|
case SMALL:
|
|
return 1;
|
|
case WIDE:
|
|
return 2;
|
|
case TALL:
|
|
return 3;
|
|
case LARGE:
|
|
return 4;
|
|
default:
|
|
throw new IllegalArgumentException("Unknown widget type " + widgetType);
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
private WidgetLayout fromRawLayout(final int rawLayout) {
|
|
switch (rawLayout) {
|
|
case 1: // 2x2, top 2x small, bottom 2x small
|
|
return WidgetLayout.TOP_2_BOT_2;
|
|
case 2: // 2x2, top wide, bottom 2x small
|
|
return WidgetLayout.TOP_1_BOT_2;
|
|
case 4: // 2x2, top 2x small, bottom wide
|
|
return WidgetLayout.TOP_2_BOT_1;
|
|
case 128: // 2x2, full screen
|
|
return WidgetLayout.TWO_BY_TWO_SINGLE;
|
|
case 256: // 1x2, top small, bottom small
|
|
return WidgetLayout.TWO;
|
|
case 512: // 1x2, full screen
|
|
return WidgetLayout.ONE_BY_TWO_SINGLE;
|
|
case 8: // 2x2, left tall, right 2x square
|
|
case 16: // 2x2, left 2x square, right tall
|
|
case 32: // 2x2, top wide, bottom wide
|
|
case 64: // 2x2, left tall, right tall
|
|
case 1024: // 2x3, top 2x square, bottom 2x2 square
|
|
case 2048: // 2x3, top 2x2 square, bottom 2x square
|
|
case 4096: // 2x3, top wide, bottom 2x2 square
|
|
case 8192: // 2x3, top 2x2 square, bottom wide
|
|
case 16384: // 2x3, full screen
|
|
default:
|
|
LOG.warn("Unknown widget screens layout {}", rawLayout);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private int toRawLayout(final WidgetLayout layout) {
|
|
if (layout == null) {
|
|
return -1;
|
|
}
|
|
|
|
switch (layout) {
|
|
case TOP_2_BOT_2:
|
|
return 1;
|
|
case TOP_1_BOT_2:
|
|
return 2;
|
|
case TOP_2_BOT_1:
|
|
return 4;
|
|
case TWO_BY_TWO_SINGLE:
|
|
return 128;
|
|
case TWO:
|
|
return 256;
|
|
case ONE_BY_TWO_SINGLE:
|
|
return 512;
|
|
default:
|
|
LOG.warn("Widget layout {} cannot be converted to raw variant", layout);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
private XiaomiProto.WidgetPart findRawPart(final int type, final int id) {
|
|
final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts();
|
|
|
|
for (final XiaomiProto.WidgetPart rawPart : rawWidgetParts.getWidgetPartList()) {
|
|
if (rawPart.getType() == type && rawPart.getId() == id) {
|
|
return rawPart;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private XiaomiProto.WidgetScreens getRawWidgetScreens() {
|
|
final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_SCREENS, null);
|
|
if (hex == null) {
|
|
LOG.warn("raw widget screens hex is null");
|
|
return XiaomiProto.WidgetScreens.newBuilder().build();
|
|
}
|
|
|
|
try {
|
|
return XiaomiProto.WidgetScreens.parseFrom(GB.hexStringToByteArray(hex));
|
|
} catch (final InvalidProtocolBufferException e) {
|
|
LOG.warn("failed to parse raw widget screns hex");
|
|
return XiaomiProto.WidgetScreens.newBuilder().build();
|
|
}
|
|
}
|
|
|
|
private XiaomiProto.WidgetParts getRawWidgetParts() {
|
|
final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_PARTS, null);
|
|
if (hex == null) {
|
|
LOG.warn("raw widget parts hex is null");
|
|
return XiaomiProto.WidgetParts.newBuilder().build();
|
|
}
|
|
|
|
try {
|
|
return XiaomiProto.WidgetParts.parseFrom(GB.hexStringToByteArray(hex));
|
|
} catch (final InvalidProtocolBufferException e) {
|
|
LOG.warn("failed to parse raw widget parts hex");
|
|
return XiaomiProto.WidgetParts.newBuilder().build();
|
|
}
|
|
}
|
|
}
|