Xiaomi: refactor WidgetManager for Redmi Watch 4

The Redmi Watch 4 reports both an unsupported widget type and layout
style:

- The firmware supports a screen layout for a single full screens
  widget, which is defined by layout ID 128;
- A full screen widget is a single 2x2 part, which is not supported.

This commit adds support for both the new layout and the new widget
type.

Furthermore, this commit refactors the XiaomiWidgetManager. Previously,
the supported layouts were determined by the types of parts supported by
the device. However, the supported layouts are reported by the device
through a bitfield in the widget capabilities message of which the purpose
was unknown, which is now used to determine the supported layouts.
This commit is contained in:
MrYoranimo 2024-04-07 19:05:56 +02:00
parent 796f26f74f
commit 0b731611b9
8 changed files with 231 additions and 144 deletions

View File

@ -194,7 +194,8 @@ public class WidgetScreenDetailsActivity extends AbstractGBActivity {
updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, 2);
updateWidget(cardWidgetBotRight, labelWidgetBotRight, 3);
break;
case SINGLE:
case ONE_BY_TWO_SINGLE:
case TWO_BY_TWO_SINGLE:
updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1);
updateWidget(cardWidgetTopRight, labelWidgetTopRight, -1);
updateWidget(cardWidgetCenter, labelWidgetCenter, 0);
@ -202,9 +203,9 @@ public class WidgetScreenDetailsActivity extends AbstractGBActivity {
updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1);
break;
case TWO:
updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0);
updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1);
updateWidget(cardWidgetCenter, labelWidgetCenter, -1);
updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1);
updateWidget(cardWidgetTopRight, labelWidgetTopRight, 0);
updateWidget(cardWidgetCenter, labelWidgetCenter, 1);
updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1);
updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1);
break;

View File

@ -21,11 +21,17 @@ import androidx.annotation.StringRes;
import nodomain.freeyourgadget.gadgetbridge.R;
public enum WidgetLayout {
// Square screen layouts, 2x2
TOP_1_BOT_2(R.string.widget_layout_top_1_bot_2, WidgetType.WIDE, WidgetType.SMALL, WidgetType.SMALL),
TOP_2_BOT_1(R.string.widget_layout_top_2_bot_1, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL),
TOP_2_BOT_2(R.string.widget_layout_top_2_bot_2, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL),
SINGLE(R.string.widget_layout_single, WidgetType.TALL),
TWO_BY_TWO_SINGLE(R.string.widget_layout_single, WidgetType.LARGE),
// Narrow screen layouts, 2x1
ONE_BY_TWO_SINGLE(R.string.widget_layout_single, WidgetType.TALL),
TWO(R.string.widget_layout_two, WidgetType.SMALL, WidgetType.SMALL),
// TODO Portrait screen layouts, 2x3
;
@StringRes

View File

@ -20,5 +20,6 @@ public enum WidgetType {
SMALL, // 1x1
TALL, // 1x2
WIDE, // 2x1
LARGE, // 2x2
;
}

View File

@ -16,6 +16,8 @@
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;
@ -25,6 +27,7 @@ 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;
@ -60,31 +63,42 @@ public class XiaomiWidgetManager implements WidgetManager {
@Override
public List<WidgetLayout> getSupportedWidgetLayouts() {
final List<WidgetLayout> layouts = new ArrayList<>();
final Set<WidgetType> partTypes = new HashSet<>();
final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts();
for (final XiaomiProto.WidgetPart widgetPart : rawWidgetParts.getWidgetPartList()) {
partTypes.add(fromRawWidgetType(widgetPart.getType()));
final XiaomiProto.WidgetScreens widgetScreens = getRawWidgetScreens();
if (!widgetScreens.hasWidgetsCapabilities() || !widgetScreens.getWidgetsCapabilities().hasSupportedLayoutStyles()) {
return Collections.emptyList();
}
if (partTypes.contains(WidgetType.WIDE) && partTypes.contains(WidgetType.SMALL)) {
layouts.add(WidgetLayout.TOP_1_BOT_2);
layouts.add(WidgetLayout.TOP_2_BOT_1);
layouts.add(WidgetLayout.TOP_2_BOT_2);
}
final int supportedBitmap = getRawWidgetScreens().getWidgetsCapabilities().getSupportedLayoutStyles();
if (partTypes.contains(WidgetType.TALL)) {
layouts.add(WidgetLayout.SINGLE);
if (partTypes.contains(WidgetType.SMALL)) {
layouts.add(WidgetLayout.TWO);
// 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<>();
@ -94,41 +108,29 @@ public class XiaomiWidgetManager implements WidgetManager {
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 WidgetType type = fromRawWidgetType(widgetPart.getType());
final WidgetPart convertedPart = fromRawWidgetPart(widgetPart, subtypes);
if (type != null && type.equals(targetWidgetType)) {
final WidgetPart newPart = new WidgetPart(
String.valueOf(widgetPart.getId()),
widgetPart.getTitle(),
type
);
if (widgetPart.getFunction() == 16) {
if (StringUtils.isBlank(newPart.getName())) {
newPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout));
}
final List<XiaomiWorkoutType> workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice());
for (final XiaomiWorkoutType workoutType : workoutTypes) {
newPart.getSupportedSubtypes().add(
new WidgetPartSubtype(
String.valueOf(workoutType.getCode()),
workoutType.getName()
)
);
Collections.sort(newPart.getSupportedSubtypes(), (p1, p2) -> p1.getName().compareToIgnoreCase(p2.getName()));
}
}
if (seenNames.contains(newPart.getFullName())) {
duplicatedNames.add(newPart.getFullName());
} else {
seenNames.add(newPart.getFullName());
}
parts.add(newPart);
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
@ -138,66 +140,94 @@ public class XiaomiWidgetManager implements WidgetManager {
}
}
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> ret = new ArrayList<>(rawWidgetScreens.getWidgetScreenCount());
final List<WidgetScreen> convertedScreens = new ArrayList<>(rawWidgetScreens.getWidgetScreenCount());
final Collection<WidgetPartSubtype> workoutTypes = convertWorkoutTypesToPartSubtypes(XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice()));
final List<XiaomiWorkoutType> workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice());
for (final XiaomiProto.WidgetScreen rawScreen : rawWidgetScreens.getWidgetScreenList()) {
final WidgetLayout layout = fromRawLayout(rawScreen.getLayout());
for (final XiaomiProto.WidgetScreen widgetScreen : rawWidgetScreens.getWidgetScreenList()) {
final WidgetLayout layout = fromRawLayout(widgetScreen.getLayout());
final List<WidgetPart> convertedParts = new ArrayList<>(rawScreen.getWidgetPartCount());
final List<WidgetPart> parts = new ArrayList<>(widgetScreen.getWidgetPartCount());
for (final XiaomiProto.WidgetPart rawPart : rawScreen.getWidgetPartList()) {
final WidgetPart convertedPart = fromRawWidgetPart(rawPart, workoutTypes);
for (final XiaomiProto.WidgetPart widgetPart : widgetScreen.getWidgetPartList()) {
final WidgetType type = fromRawWidgetType(widgetPart.getType());
final WidgetPart newPart = new WidgetPart(
String.valueOf(widgetPart.getId()),
"Unknown (" + widgetPart.getId() + ")",
type
);
// Find the name
final XiaomiProto.WidgetPart rawPart1 = findRawPart(widgetPart.getType(), widgetPart.getId());
if (rawPart1 != null) {
newPart.setName(rawPart1.getTitle());
if (convertedPart == null) {
LOG.warn("Widget cannot be converted, result was null for following raw widget: {}", rawPart);
continue;
}
if (widgetPart.getFunction() == 16) {
if (StringUtils.isBlank(newPart.getName())) {
newPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout));
}
}
// Get the proper subtype, if any
if (widgetPart.getSubType() != 0) {
for (final XiaomiWorkoutType workoutType : workoutTypes) {
if (workoutType.getCode() == widgetPart.getSubType()) {
newPart.setSubtype(new WidgetPartSubtype(
String.valueOf(workoutType.getCode()),
workoutType.getName()
));
}
}
}
parts.add(newPart);
convertedParts.add(convertedPart);
}
ret.add(new WidgetScreen(
String.valueOf(widgetScreen.getId()),
convertedScreens.add(new WidgetScreen(
String.valueOf(rawScreen.getId()),
layout,
parts
convertedParts
));
}
return ret;
return convertedScreens;
}
@Override
@ -219,26 +249,9 @@ public class XiaomiWidgetManager implements WidgetManager {
public void saveScreen(final WidgetScreen widgetScreen) {
final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
final int layoutNum;
switch (widgetScreen.getLayout()) {
case TOP_2_BOT_2:
layoutNum = 1;
break;
case TOP_1_BOT_2:
layoutNum = 2;
break;
case TOP_2_BOT_1:
layoutNum = 4;
break;
case TWO:
layoutNum = 256;
break;
case SINGLE:
layoutNum = 512;
break;
default:
LOG.warn("Unknown widget screens layout {}", widgetScreen.getLayout());
return;
final int layoutNum = toRawLayout(widgetScreen.getLayout());
if (layoutNum == -1) {
return;
}
XiaomiProto.WidgetScreen.Builder rawScreen = null;
@ -265,6 +278,8 @@ public class XiaomiWidgetManager implements WidgetManager {
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(
@ -274,14 +289,21 @@ public class XiaomiWidgetManager implements WidgetManager {
final XiaomiProto.WidgetPart.Builder newRawPartBuilder = XiaomiProto.WidgetPart.newBuilder(knownRawPart);
// TODO only support subtypes on widget with type 16
if (newPart.getSubtype() != null) {
// Get the workout type as subtype
final List<XiaomiWorkoutType> workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice());
for (final XiaomiWorkoutType workoutType : workoutTypes) {
if (newPart.getSubtype().getId().equals(String.valueOf(workoutType.getCode()))) {
newRawPartBuilder.setSubType(workoutType.getCode());
break;
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);
}
}
@ -355,6 +377,8 @@ public class XiaomiWidgetManager implements WidgetManager {
return WidgetType.WIDE;
case 3:
return WidgetType.TALL;
case 4:
return WidgetType.LARGE;
default:
LOG.warn("Unknown widget type {}", rawType);
return null;
@ -369,6 +393,8 @@ public class XiaomiWidgetManager implements WidgetManager {
return 2;
case TALL:
return 3;
case LARGE:
return 4;
default:
throw new IllegalArgumentException("Unknown widget type " + widgetType);
}
@ -377,22 +403,57 @@ public class XiaomiWidgetManager implements WidgetManager {
@Nullable
private WidgetLayout fromRawLayout(final int rawLayout) {
switch (rawLayout) {
case 1:
case 1: // 2x2, top 2x small, bottom 2x small
return WidgetLayout.TOP_2_BOT_2;
case 2:
case 2: // 2x2, top wide, bottom 2x small
return WidgetLayout.TOP_1_BOT_2;
case 4:
case 4: // 2x2, top 2x small, bottom wide
return WidgetLayout.TOP_2_BOT_1;
case 256:
case 128: // 2x2, full screen
return WidgetLayout.TWO_BY_TWO_SINGLE;
case 256: // 1x2, top small, bottom small
return WidgetLayout.TWO;
case 512:
return WidgetLayout.SINGLE;
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();

View File

@ -18,7 +18,16 @@ package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
import androidx.annotation.StringRes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class XiaomiWorkoutType {
private final int code;
@ -58,4 +67,23 @@ public class XiaomiWorkoutType {
return -1;
}
public static Collection<XiaomiWorkoutType> getWorkoutTypesSupportedByDevice(final GBDevice device) {
final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
final List<String> codes = prefs.getList(XiaomiPreferences.PREF_WORKOUT_TYPES, Collections.emptyList());
final List<XiaomiWorkoutType> ret = new ArrayList<>(codes.size());
for (final String code : codes) {
final int codeInt = Integer.parseInt(code);
final int codeNameStringRes = XiaomiWorkoutType.mapWorkoutName(codeInt);
ret.add(new XiaomiWorkoutType(
codeInt,
codeNameStringRes != -1 ?
GBApplication.getContext().getString(codeNameStringRes) :
GBApplication.getContext().getString(R.string.widget_unknown_workout, code)
));
}
return ret;
}
}

View File

@ -91,22 +91,4 @@ public final class XiaomiPreferences {
final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
return prefs.getBoolean("keep_activity_data_on_device", false);
}
// FIXME this function should not be here
public static List<XiaomiWorkoutType> getWorkoutTypes(final GBDevice gbDevice) {
final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
final List<String> codes = prefs.getList(PREF_WORKOUT_TYPES, Collections.emptyList());
final List<XiaomiWorkoutType> ret = new ArrayList<>(codes.size());
for (final String code : codes) {
final int codeInt = Integer.parseInt(code);
final int codeNameStringRes = XiaomiWorkoutType.mapWorkoutName(codeInt);
ret.add(new XiaomiWorkoutType(
codeInt,
codeNameStringRes != -1 ?
GBApplication.getContext().getString(codeNameStringRes) :
GBApplication.getContext().getString(R.string.widget_unknown_workout, code)
));
}
return ret;
}
}

View File

@ -238,7 +238,13 @@ message WidgetScreens {
message WidgetsCapabilities {
optional uint32 minWidgets = 1; // 1
optional uint32 maxWidgets = 2; // 7
optional uint32 unknown3 = 3; // 768
// bitmap:
// - 0b0000_0011_0000_0000 (768) on bands
// - 0b0000_0000_0000_0111 (7) on some square/round devices (Watch S1 Active)
// - 0b0000_0000_1000_0111 (135) on some square/round devices (Redmi Watch 4)
// - 0b0111_1100_0000_0000 (31744) on portrait devices (Band 8 Pro)
optional uint32 supportedLayoutStyles = 3;
}
message WidgetScreen {

View File

@ -2707,6 +2707,8 @@
<string name="widget_move_down">Move down</string>
<string name="widget_missing_parts">Please select all widgets</string>
<string name="widget_unknown_workout">Unknown workout - %s</string>
<string name="widget_name_colored_tile">%1$s (colored tile)</string>
<string name="widget_name_untitled">Untitled widget (%1$s)</string>
<string name="pref_title_fossil_hr_navigation_instructions">Navigation instructions</string>
<string name="pref_summary_fossil_hr_navigation_instructions">Configure on-watch navigation app behavior</string>
<string name="pref_title_fossil_hr_nav_foreground">Come to foreground</string>