Fossil Hybrid HR: Add flexible custom menu on watch (#2616)

This PR adds support for a new custom menu system on the Fossil Hybrid HR, developed by @dakhnod. For regular users this PR will change nothing, apart from also giving an extra option to light up the display when receiving new notifications.
For more advanced users, there's the [Fossil HR Menu Companion app](https://github.com/dakhnod/Fossil-HR-Menu-Companion) that's needed to enable and configure the new menu system. Just disable one or more of the physical buttons in Gadgetbridge and configure them instead in the companion app to get started.

Co-authored-by: Daniel Dakhno <dakhnod@gmail.com>
Co-authored-by: Arjan Schrijver <a_gadgetbridge@anymore.nl>
Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2616
This commit is contained in:
Arjan Schrijver 2022-03-22 20:52:43 +01:00
parent 677d8503d9
commit e4d8f5f3e2
17 changed files with 266 additions and 209 deletions

2
.gitmodules vendored
View File

@ -1,6 +1,6 @@
[submodule "fossil-hr-watchface"]
path = external/fossil-hr-watchface
url = https://github.com/arjan-s/fossil-hr-watchface
url = https://codeberg.org/Freeyourgadget/fossil-hr-watchface
[submodule "jerryscript"]
path = external/jerryscript
url = https://github.com/jerryscript-project/jerryscript

View File

@ -0,0 +1 @@
[{"id":0,"type":"complication_background","background":"#background","goal_ring":{"is_enable":"#goal_ring","end_angle":"#fi","is_invert":"#$e"},"dimension":{"type":"rigid","width":"#size.w","height":"#size.h"},"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"complication_content","icon":"icBattery","text_low":"#ci","dimension":{"type":"rigid","width":76,"height":76},"placement":{"type":"relative"},"visible":true,"inversion":"#$e"},{"id":2,"parent_id":1,"type":"solid","placement":{"type":"absolute","left":29,"top":23},"color":"#nt","dimension":{"type":"rigid","height":6,"width":"#it"},"visible":true,"inversion":false},{"id":3,"parent_id":1,"type":"image","image_name":"icBattCharging","draw_mode":1,"placement":{"type":"absolute","left":34,"top":21},"dimension":{"width":6,"height":9},"visible":"#et","inversion":false}]

View File

@ -0,0 +1 @@
[{"id":0,"type":"complication_background","background":"#background","goal_ring":{"is_enable":"#goal_ring","end_angle":"#fi","is_invert":"#$e"},"dimension":{"type":"rigid","width":"#size.w","height":"#size.h"},"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"complication_content","icon":"#icon","text_high":"#dt","text_low":"#ci","dimension":{"type":"rigid","width":76,"height":76},"placement":{"type":"relative"},"visible":true,"inversion":"#$e"}]

View File

@ -0,0 +1 @@
[{"id":0,"type":"container","direction":1,"main_alignment":1,"cross_alignment":1,"dimension":{"type":"rigid","width":240,"height":240},"placement":{"type":"absolute","left":0,"top":0},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"image","image_name":"#name","draw_mode":1,"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"dimension":{"width":"#size.w","height":"#size.h"},"visible":true,"inversion":false}]

View File

@ -0,0 +1,207 @@
[
{
"id": 0,
"type": "container",
"direction": 1,
"main_alignment": 0,
"cross_alignment": 1,
"dimension": {
"type": "rigid",
"width": 240,
"height": 240
},
"placement": {
"type": "absolute",
"left": 0,
"top": 0
},
"visible": true,
"inversion": false
},
{
"id": 1,
"parent_id": 0,
"type": "container",
"direction": 1,
"main_alignment": 1,
"cross_alignment": 2,
"dimension": {
"type": "rigid",
"width": 130,
"height": 34
},
"placement": {
"type": "absolute",
"left": 75,
"top": 45
},
"visible": true,
"inversion": false
},
{
"id": 2,
"parent_id": 1,
"type": "text",
"text": "#top_short_press_label",
"ppem": 17,
"color": 3,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 3,
"parent_id": 1,
"type": "text",
"text": "#top_long_press_label",
"ppem": 17,
"color": 3,
"ascent": 17,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 4,
"parent_id": 0,
"type": "container",
"direction": 1,
"main_alignment": 1,
"cross_alignment": 2,
"dimension": {
"type": "rigid",
"width": 80,
"height": 34
},
"placement": {
"type": "absolute",
"left": 135,
"top": 103
},
"visible": true,
"inversion": false
},
{
"id": 5,
"parent_id": 4,
"type": "text",
"text": "#middle_short_press_label",
"ppem": 17,
"color": 3,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 6,
"parent_id": 4,
"type": "text",
"text": "#middle_long_press_label",
"ppem": 17,
"color": 3,
"ascent": 17,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 7,
"parent_id": 0,
"type": "container",
"direction": 1,
"main_alignment": 1,
"cross_alignment": 2,
"dimension": {
"type": "rigid",
"width": 130,
"height": 34
},
"placement": {
"type": "absolute",
"left": 75,
"top": 161
},
"visible": true,
"inversion": false
},
{
"id": 8,
"parent_id": 7,
"type": "text",
"text": "#bottom_short_press_label",
"ppem": 17,
"color": 3,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 9,
"parent_id": 7,
"type": "text",
"text": "#bottom_long_press_label",
"ppem": 17,
"color": 3,
"ascent": 17,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 10,
"parent_id": 0,
"type": "text",
"text": "#menu_title",
"ppem": 25,
"color": 3,
"ascent": 35,
"placement": {
"type": "relative"
},
"visible": true,
"inversion": false
},
{
"id": 11,
"parent_id": 0,
"type": "text_page",
"text": "#message_to_display",
"ppem": 17,
"color": 3,
"ascent": 17,
"cross_alignment": 2,
"line_width": [
80,
85,
90,
90,
85,
80
],
"dimension": {
"type": "rigid",
"width": 80,
"height": 100
},
"placement": {
"type": "absolute",
"left": 20,
"top": 60
},
"visible": true,
"inversion": false
}
]

View File

@ -39,11 +39,11 @@ public class FossilAppWriter {
private String version;
private LinkedHashMap<String, InputStream> code;
private LinkedHashMap<String, InputStream> icons;
private LinkedHashMap<String, String> layout;
private LinkedHashMap<String, InputStream> layout;
private LinkedHashMap<String, String> displayName;
private LinkedHashMap<String, String> config;
public FossilAppWriter(Context context, String version, LinkedHashMap<String, InputStream> code, LinkedHashMap<String, InputStream> icons, LinkedHashMap<String, String> layout, LinkedHashMap<String, String> displayName, LinkedHashMap<String, String> config) {
public FossilAppWriter(Context context, String version, LinkedHashMap<String, InputStream> code, LinkedHashMap<String, InputStream> icons, LinkedHashMap<String, InputStream> layout, LinkedHashMap<String, String> displayName, LinkedHashMap<String, String> config) {
this.mContext = context;
if (this.mContext == null) throw new AssertionError("context cannot be null");
this.version = version;
@ -61,9 +61,9 @@ public class FossilAppWriter {
}
public byte[] getWapp() throws IOException {
byte[] codeData = loadFiles(code);
byte[] iconsData = loadFiles(icons);
byte[] layoutData = loadStringFiles(layout);
byte[] codeData = loadFiles(code, false);
byte[] iconsData = loadFiles(icons, false);
byte[] layoutData = loadFiles(layout, true);
byte[] displayNameData = loadStringFiles(displayName);
byte[] configData = loadStringFiles(config);
@ -118,16 +118,23 @@ public class FossilAppWriter {
return wapp.toByteArray();
}
public byte[] loadFiles(LinkedHashMap<String, InputStream> filesMap) throws IOException {
public byte[] loadFiles(LinkedHashMap<String, InputStream> filesMap, boolean appendNull) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (String filename : filesMap.keySet()) {
InputStream in = filesMap.get(filename);
output.write((byte)filename.length() + 1);
output.write(StringUtils.terminateNull(filename).getBytes(StandardCharsets.UTF_8));
output.write(shortToLEBytes((short)in.available()));
int fileLength = in.available();
if(appendNull){
fileLength++;
}
output.write(shortToLEBytes((short)fileLength));
byte[] fileBytes = new byte[in.available()];
in.read(fileBytes);
output.write(fileBytes);
if(appendNull){
output.write(0x00);
}
}
return output.toByteArray();
}

View File

@ -456,6 +456,9 @@ public class HybridHRWatchfaceDesignerActivity extends AbstractGBActivity implem
if (watchfaceConfig.has("powersave_hands")) {
watchfaceSettings.setPowersaveHands(watchfaceConfig.getBoolean("powersave_hands"));
}
if (watchfaceConfig.has("light_up_on_notification")) {
watchfaceSettings.setLightUpOnNotification(watchfaceConfig.getBoolean("light_up_on_notification"));
}
} catch (JSONException e) {
LOG.warn("JSON parsing error", e);
}

View File

@ -185,22 +185,15 @@ public class HybridHRWatchfaceFactory {
} catch (IOException e) {
LOG.warn("Unable to read asset file", e);
}
LinkedHashMap<String, String> layout = new LinkedHashMap<>();
try {
layout.put("complication_layout", getComplicationLayout());
} catch (JSONException e) {
LOG.warn("Could not generate complication_layout", e);
}
try {
layout.put("image_layout", getImageLayout());
} catch (JSONException e) {
LOG.warn("Could not generate image_layout", e);
}
try {
if (includeWidget("widgetBattery") > 0) layout.put("battery_layout", getBatteryLayout());
} catch (JSONException e) {
LOG.warn("Could not generate battery_layout", e);
LinkedHashMap<String, InputStream> layout = new LinkedHashMap<>();
layout.put("complication_layout", context.getAssets().open("fossil_hr/complication_layout.json"));
layout.put("image_layout", context.getAssets().open("fossil_hr/image_layout.json"));
layout.put("menu_layout", context.getAssets().open("fossil_hr/menu_layout.json"));
if (includeWidget("widgetBattery") > 0) {
layout.put("battery_layout", context.getAssets().open("fossil_hr/battery_layout.json"));
}
LinkedHashMap<String, String> displayName = new LinkedHashMap<>();
displayName.put("display_name", watchfaceName);
displayName.put("theme_class", "complications");
@ -214,184 +207,6 @@ public class HybridHRWatchfaceFactory {
return appWriter.getWapp();
}
private String getBatteryLayout() throws JSONException {
JSONArray batteryLayout = new JSONArray();
JSONObject complicationBackground = new JSONObject();
complicationBackground.put("id", 0);
complicationBackground.put("type", "complication_background");
complicationBackground.put("background", "#background");
complicationBackground.put("visible", true);
complicationBackground.put("inversion", false);
JSONObject goalRing = new JSONObject();
goalRing.put("is_enable", "#goal_ring");
goalRing.put("end_angle", "#fi");
goalRing.put("is_invert", "#$e");
complicationBackground.put("goal_ring", goalRing);
JSONObject dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
complicationBackground.put("dimension", dimension);
JSONObject placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", "#pos.Ue");
placement.put("top", "#pos.Qe");
complicationBackground.put("placement", placement);
batteryLayout.put(complicationBackground);
JSONObject complicationContent = new JSONObject();
complicationContent.put("id", 1);
complicationContent.put("parent_id", 0);
complicationContent.put("type", "complication_content");
complicationContent.put("icon", "icBattery");
complicationContent.put("text_low", "#ci");
complicationContent.put("visible", true);
complicationContent.put("inversion", "#$e");
dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", 76);
dimension.put("height", 76);
complicationContent.put("dimension", dimension);
placement = new JSONObject();
placement.put("type", "relative");
complicationContent.put("placement", placement);
batteryLayout.put(complicationContent);
JSONObject chargingStatus = new JSONObject();
chargingStatus.put("id", 2);
chargingStatus.put("parent_id", 1);
chargingStatus.put("type", "solid");
chargingStatus.put("color", "#nt");
chargingStatus.put("visible", true);
chargingStatus.put("inversion", false);
dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", "#it");
dimension.put("height", 6);
chargingStatus.put("dimension", dimension);
placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", 29);
placement.put("top", 23);
chargingStatus.put("placement", placement);
batteryLayout.put(chargingStatus);
JSONObject image = new JSONObject();
image.put("id", 3);
image.put("parent_id", 1);
image.put("type", "image");
image.put("image_name", "icBattCharging");
image.put("draw_mode", 1);
image.put("visible", "#et");
image.put("inversion", false);
placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", 34);
placement.put("top", 21);
image.put("placement", placement);
dimension = new JSONObject();
dimension.put("width", 6);
dimension.put("height", 9);
image.put("dimension", dimension);
batteryLayout.put(image);
return batteryLayout.toString();
}
private String getComplicationLayout() throws JSONException {
JSONArray complicationLayout = new JSONArray();
JSONObject complicationBackground = new JSONObject();
complicationBackground.put("id", 0);
complicationBackground.put("type", "complication_background");
complicationBackground.put("background", "#background");
complicationBackground.put("visible", true);
complicationBackground.put("inversion", false);
JSONObject goalRing = new JSONObject();
goalRing.put("is_enable", "#goal_ring");
goalRing.put("end_angle", "#fi");
goalRing.put("is_invert", "#$e");
complicationBackground.put("goal_ring", goalRing);
JSONObject dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
complicationBackground.put("dimension", dimension);
JSONObject placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", "#pos.Ue");
placement.put("top", "#pos.Qe");
complicationBackground.put("placement", placement);
complicationLayout.put(complicationBackground);
JSONObject complicationContent = new JSONObject();
complicationContent.put("id", 1);
complicationContent.put("parent_id", 0);
complicationContent.put("type", "complication_content");
complicationContent.put("icon", "#icon");
complicationContent.put("text_high", "#dt");
complicationContent.put("text_low", "#ci");
complicationContent.put("visible", true);
complicationContent.put("inversion", "#$e");
dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
complicationContent.put("dimension", dimension);
placement = new JSONObject();
placement.put("type", "relative");
complicationContent.put("placement", placement);
complicationLayout.put(complicationContent);
return complicationLayout.toString();
}
private String getImageLayout() throws JSONException {
JSONArray imageLayout = new JSONArray();
JSONObject container = new JSONObject();
container.put("id", 0);
container.put("type", "container");
container.put("direction", 1);
container.put("main_alignment", 1);
container.put("cross_alignment", 1);
container.put("visible", true);
container.put("inversion", false);
JSONObject dimension = new JSONObject();
dimension.put("type", "rigid");
dimension.put("width", 240);
dimension.put("height", 240);
container.put("dimension", dimension);
JSONObject placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", 0);
placement.put("top", 0);
container.put("placement", placement);
imageLayout.put(container);
JSONObject image = new JSONObject();
image.put("id", 1);
image.put("parent_id", 0);
image.put("type", "image");
image.put("image_name", "#name");
image.put("draw_mode", 1);
image.put("visible", true);
image.put("inversion", false);
placement = new JSONObject();
placement.put("type", "absolute");
placement.put("left", "#pos.Ue");
placement.put("top", "#pos.Qe");
image.put("placement", placement);
dimension = new JSONObject();
dimension.put("width", "#size.w");
dimension.put("height", "#size.h");
image.put("dimension", dimension);
imageLayout.put(image);
return imageLayout.toString();
}
private String getConfiguration() throws JSONException {
JSONObject configuration = new JSONObject();
@ -432,6 +247,7 @@ public class HybridHRWatchfaceFactory {
config.put("wrist_flick_duration", settings.getWristFlickDuration());
config.put("wrist_flick_move_hour", settings.getWristFlickMoveHour());
config.put("wrist_flick_move_minute", settings.getWristFlickMoveMinute());
config.put("light_up_on_notification", settings.getLightUpOnNotification());
config.put("powersave_display", settings.getPowersaveDisplay());
config.put("powersave_hands", settings.getPowersaveHands());
configuration.put("config", config);

View File

@ -27,6 +27,7 @@ public class HybridHRWatchfaceSettings implements Serializable {
private int wristFlickMoveMinute = -360;
private boolean powersaveDisplay = false;
private boolean powersaveHands = false;
private boolean lightUpOnNotification = false;
public HybridHRWatchfaceSettings() {
}
@ -63,6 +64,14 @@ public class HybridHRWatchfaceSettings implements Serializable {
this.wristFlickDuration = wristFlickDuration;
}
public boolean getLightUpOnNotification() {
return lightUpOnNotification;
}
public void setLightUpOnNotification(boolean lightUpOnNotification) {
this.lightUpOnNotification = lightUpOnNotification;
}
public int getWristFlickMoveHour() {
return wristFlickMoveHour;
}

View File

@ -111,6 +111,10 @@ public class HybridHRWatchfaceSettingsActivity extends AbstractSettingsActivity
SwitchPreference power_saving_hands = (SwitchPreference) findPreference("pref_hybridhr_watchface_power_saving_hands");
power_saving_hands.setOnPreferenceChangeListener(new PreferenceChangeListener());
power_saving_hands.setChecked(settings.getPowersaveHands());
SwitchPreference light_up_on_notification = (SwitchPreference) findPreference("pref_hybridhr_watchface_light_up_on_notification");
light_up_on_notification.setOnPreferenceChangeListener(new PreferenceChangeListener());
light_up_on_notification.setChecked(settings.getLightUpOnNotification());
}
private static class PreferenceChangeListener implements Preference.OnPreferenceChangeListener {
@ -136,6 +140,9 @@ public class HybridHRWatchfaceSettingsActivity extends AbstractSettingsActivity
settings.setWristFlickMoveMinute(Integer.parseInt(newValue.toString()));
preference.setSummary(newValue.toString());
break;
case "pref_hybridhr_watchface_light_up_on_notification":
settings.setLightUpOnNotification((boolean) newValue);
break;
case "pref_hybridhr_watchface_wrist_flick_duration":
settings.setWristFlickDuration(Integer.parseInt(newValue.toString()));
preference.setSummary(newValue.toString());

View File

@ -1559,8 +1559,6 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
return;
}
queueWrite(new SetCommuteMenuMessage(getContext().getString(R.string.fossil_hr_commute_processing), false, this));
Intent menuIntent = new Intent(QHybridSupport.QHYBRID_EVENT_COMMUTE_MENU);
menuIntent.putExtra("EXTRA_ACTION", action);
getContext().sendBroadcast(menuIntent);

View File

@ -1593,4 +1593,4 @@
<string name="pref_summary_opentracks_packagename">Dient zum Starten/Stoppen der GPS-Track-Aufzeichnung in der externen Fitness-App.</string>
<string name="pref_title_notifications_generic_settings">Android-Benachrichtigungseinstellungen</string>
<string name="watchface_dialog_pre_setting_position">setze position auf %s</string>
</resources>
</resources>

View File

@ -1528,4 +1528,5 @@
<string name="pref_title_opentracks_packagename">OpenTracks package name</string>
<string name="pref_summary_opentracks_packagename">Used for starting/stopping GPS track recording in external fitness app.</string>
<string name="watchface_dialog_pre_setting_position">pre-setting position to %s</string>
<string name="watchface_setting_light_up_on_notification">Light up on new notification</string>
</resources>

View File

@ -41,11 +41,12 @@
android:key="button_3_function_short"
android:summary="%s"
android:title="@string/pref_title_lower_button_function_short" />
<Preference
android:selectable="false"
android:persistent="false"
<ListPreference
android:defaultValue="musicApp"
android:entries="@array/pref_hybridhr_buttonfunctions"
android:entryValues="@array/pref_hybridhr_buttonfunctions_values"
android:key="button_3_function_long"
android:summary="@string/menuitem_notifications"
android:summary="%s"
android:title="@string/pref_title_lower_button_function_long" />
<Preference
android:selectable="false"

View File

@ -57,6 +57,11 @@
android:key="pref_hybridhr_watchface_power_saving_hands"
android:title="@string/watchface_setting_power_saving_hands"
android:singleLineTitle="false" />
<SwitchPreference
android:persistent="false"
android:key="pref_hybridhr_watchface_light_up_on_notification"
android:title="@string/watchface_setting_light_up_on_notification"
android:singleLineTitle="false" />
</PreferenceCategory>
</PreferenceScreen>

@ -1 +1 @@
Subproject commit 44a70cf7c3a783d07d0fdab8b4b15e677da63af2
Subproject commit aad2a141cb2e151431f8354e52d9b74f6829855a