Even Realites G1: Support Notifications and improve disconnections

This change helps improve the connection experience when GadgetBridge is
working as a background task. The glasses must be send a heartbeat
packet every 32 seconds, otherwise they will disconnect. When
GadgetBridge is running in the background there is a 15-20 second delay
between the background handler waking up. This means that when the wake
up time is sceduled for 30 seconds, it often won't be woken up for 45-50
seconds leading to almost immediate disconnect after the user turns off
the screen. The official app gets around this by using a 5 second delay
leading to ~25 seconds of delay in sleep mode, but this over sends when
the phone is awake. Instead of doing that, I maintain the 28 second
delay but I change the background thread to a higher priority so that it
more consistently wakes up within th 32 second BLE limit.

Also added support for Notifications being sent to the glasses.
This commit is contained in:
jrthomas270
2025-08-10 22:34:56 +02:00
committed by José Rebelo
parent 67613a321c
commit d6daab8d0d
10 changed files with 456 additions and 55 deletions
@@ -293,6 +293,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_NOTIFICATION_WAKE_ON_OPEN = "notification_wake_on_open";
public static final String PREF_AUTOREMOVE_NOTIFICATIONS = "autoremove_notifications";
public static final String PREF_SCREEN_ON_ON_NOTIFICATIONS = "screen_on_on_notifications";
public static final String PREF_SCREEN_ON_ON_NOTIFICATIONS_TIMEOUT = "screen_on_on_notifications_timeout";
public static final String PREF_WORKOUT_KEEP_SCREEN_ON = "workout_keep_screen_on";
public static final String PREF_OPERATING_SOUNDS = "operating_sounds";
public static final String PREF_KEY_VIBRATION = "key_vibration";
@@ -659,6 +659,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_CASIO_ALERT_CALENDAR);
addPreferenceHandlerFor(PREF_CASIO_ALERT_OTHER);
addPreferenceHandlerFor(PREF_SCREEN_ON_ON_NOTIFICATIONS);
addPreferenceHandlerFor(PREF_SCREEN_ON_ON_NOTIFICATIONS_TIMEOUT);
addPreferenceHandlerFor(PREF_WORKOUT_KEEP_SCREEN_ON);
addPreferenceHandlerFor(PREF_KEY_VIBRATION);
addPreferenceHandlerFor(PREF_OPERATING_SOUNDS);
@@ -198,8 +198,11 @@ public class G1DeviceCoordinator extends AbstractBLEDeviceCoordinator {
public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
if (device.isConnected()) {
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_screen_on_on_notifications);
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_screen_on_on_notifications_timeout);
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_even_realities_g1_display);
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_timeformat);
final List<Integer> developer =
deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
developer.add(R.xml.devicesettings_header_system);
@@ -1,12 +1,21 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.evenrealities;
import android.util.Pair;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
@@ -73,7 +82,7 @@ public class G1Communications {
return hasRetryRemaining();
}
public byte[] getResponsePayload(){
public byte[] getResponsePayload() {
if (responsePayload == null) {
throw new RuntimeException("Null payload. Did you call waitForPayload()?");
}
@@ -97,6 +106,79 @@ public class G1Communications {
public abstract String getName();
}
/**
* Certain payloads are too large for one packet, so this class is a simple extension of the
* CommandHandler that allows the subclass to send multiple packets.
* This works by forcing the caller to pass in a callback to the "send" function they are using
* and the ChunkCommandHandler will intercept calls to the Command callback to send the next
* chunk in the packet. Once the response for the last chunk is sent, the passed in callback
* will be sent.
*/
public abstract static class ChunkedCommandHandler extends CommandHandler {
private final Consumer<CommandHandler> sendCallback;
private byte currentChunk;
protected final byte[] payload;
protected final byte chunkCount;
public ChunkedCommandHandler(Consumer<CommandHandler> sendCallback, Function<byte[], Boolean> callback, byte[] payload) {
super(true, callback);
this.sendCallback = sendCallback;
this.currentChunk = 0;
this.payload = payload;
int maxChunkSize = G1Constants.MAX_PACKET_SIZE_BYTES - getHeaderSize();
this.chunkCount = (byte)((this.payload.length / maxChunkSize) + 1);
}
@Override
final public Function<byte[], Boolean> getCallback() {
if (currentChunk < chunkCount) {
// Return the callback which sends the next chunk.
return this::sendNextChunk;
} else {
// Now that all the chunks have been received, return the user callback.
return super.getCallback();
}
}
@Override
final public byte[] serialize() {
// Calculate the size, begin and end of the chunk.
int maxPayloadSize = G1Constants.MAX_PACKET_SIZE_BYTES - getHeaderSize();
int chunkBegin = this.currentChunk * maxPayloadSize;
int chunkEnd = Math.min(this.payload.length,
(this.currentChunk + 1) * maxPayloadSize);
int payloadSize = chunkEnd - chunkBegin;
// Create the packet with space for the header.
byte[] packet = new byte[getHeaderSize() + payloadSize];
// Let the subclass write the header.
writeHeader(this.currentChunk, this.chunkCount, packet);
// Copy the chunk of the payload into the packet.
System.arraycopy(this.payload, chunkBegin, packet, getHeaderSize(), payloadSize);
// Advance the chunk.
currentChunk++;
return packet;
}
@Override
final public boolean responseMatches(byte[] payload) {
return chunkMatches(currentChunk, payload);
}
private boolean sendNextChunk(byte[] payload) {
sendCallback.accept(this);
return true;
}
protected abstract boolean chunkMatches(byte currentChunk, byte[] payload);
protected abstract void writeHeader(byte currentChunk, byte chunkCount, byte[] chunk);
protected abstract int getHeaderSize();
}
public static class CommandSendInit extends CommandHandler {
public CommandSendInit() {
super(true, null);
@@ -191,7 +273,7 @@ public class G1Communications {
return "get_battery_info";
}
public static byte getBatteryPercent(byte[] payload) {
public static int getBatteryPercent(byte[] payload) {
return payload[2];
}
}
@@ -239,8 +321,12 @@ public class G1Communications {
if (weatherInfo != null) {
this.weatherIcon = G1Constants.fromOpenWeatherCondition(weatherInfo.getCurrentConditionCode());
// Convert sunny to a moon if the current time stamp is between sunrise and sunset.
if (timeMilliseconds / 1000 >= weatherInfo.getSunSet() &&
this.weatherIcon == G1Constants.WeatherId.SUNNY) {
// At midnight, the sunrise and sunset time move forward.
boolean isNight = (weatherInfo.getTimestamp() >= weatherInfo.getSunSet() &&
weatherInfo.getTimestamp() >= weatherInfo.getSunRise()) ||
(weatherInfo.getTimestamp() < weatherInfo.getSunRise() &&
weatherInfo.getTimestamp() < weatherInfo.getSunSet());
if (this.weatherIcon == G1Constants.WeatherId.SUNNY && isNight) {
this.weatherIcon = G1Constants.WeatherId.NIGHT;
}
// Convert Kelvin -> Celsius.
@@ -421,11 +507,11 @@ public class G1Communications {
return "get_display_settings";
}
public static byte getHeight(byte[] payload) {
public static int getHeight(byte[] payload) {
return payload[2];
}
public static byte getDepth(byte[] payload) {
public static int getDepth(byte[] payload) {
return payload[3];
}
}
@@ -492,7 +578,7 @@ public class G1Communications {
return "get_head_gesture_settings";
}
public static byte getActivationAngle(byte[] payload) {
public static int getActivationAngle(byte[] payload) {
return payload[2];
}
}
@@ -547,7 +633,7 @@ public class G1Communications {
return "get_brightness_settings";
}
public static byte getBrightnessLevel(byte[] payload) {
public static int getBrightnessLevel(byte[] payload) {
return payload[2];
}
@@ -685,7 +771,220 @@ public class G1Communications {
}
public static String getSerialNumber(byte[] payload) {
return new String(payload, 2, 16, StandardCharsets.US_ASCII);
return new String(payload, 2, 14, StandardCharsets.US_ASCII);
}
}
public static class CommandGetNotificationDisplaySettings extends CommandHandler {
public CommandGetNotificationDisplaySettings(Function<byte[], Boolean> callback) {
super(true, callback);
}
@Override
public byte[] serialize() {
return new byte[] { G1Constants.CommandId.GET_NOTIFICATION_DISPLAY_SETTINGS.id };
}
@Override
public boolean responseMatches(byte[] payload) {
return payload.length >= 4 && payload[0] == G1Constants.CommandId.GET_NOTIFICATION_DISPLAY_SETTINGS.id;
}
@Override
public String getName() {
return "get_notification_display_settings";
}
public static boolean isEnabled(byte[] payload) {
return payload[2] == 0x01;
}
public static int getTimeout(byte[] payload) {
return payload[3];
}
}
public static class CommandSetNotificationDisplaySettings extends CommandHandler {
private final boolean enable;
private final byte timeout;
public CommandSetNotificationDisplaySettings(boolean enable, byte timeout) {
super(true, null);
this.enable = enable;
this.timeout = timeout;
}
@Override
public byte[] serialize() {
return new byte[] {
G1Constants.CommandId.SET_NOTIFICATION_DISPLAY_SETTINGS.id,
enable ? 0x01 : (byte)0x00,
timeout
};
}
@Override
public boolean responseMatches(byte[] payload) {
return payload.length >= 2 && payload[0] == G1Constants.CommandId.SET_NOTIFICATION_DISPLAY_SETTINGS.id;
}
@Override
public String getName() {
return "set_notification_display_settings_" + (enable ? "enabled" : "disabled") + "_" + timeout;
}
}
public static class CommandSetAppNotificationSettings extends ChunkedCommandHandler {
public CommandSetAppNotificationSettings(Consumer<CommandHandler> sendCallback,
List<Pair<String, String>> appIdentifiers,
boolean enableCalendar,
boolean enableCalls,
boolean enableSMS) {
super(sendCallback, null,
generatePayload(appIdentifiers, enableCalendar, enableCalls, enableSMS));
}
private static byte[] generatePayload(List<Pair<String, String>> appIdentifiers,
boolean enableCalendar,
boolean enableCalls,
boolean enableSMS) {
try {
JSONObject appJson = new JSONObject();
JSONArray appList = new JSONArray();
for (Pair<String, String> appInfo : appIdentifiers) {
JSONObject app = new JSONObject();
app.put("id", appInfo.first);
app.put("name", appInfo.second);
appList.put(app);
}
appJson.put("list", appList);
appJson.put("enable", true);
JSONObject json = new JSONObject();
json.put("calendar_enable", enableCalendar);
json.put("call_enable", enableCalls);
json.put("msg_enable", enableSMS);
json.put("ios_mail_enable", false);
json.put("app", appJson);
// Need to allocate one larger in order to null terminate.
String jsonString = json.toString();
byte[] bytes = new byte[jsonString.length() + 1];
System.arraycopy(jsonString.getBytes(StandardCharsets.US_ASCII), 0, bytes, 0, jsonString.length());
bytes[jsonString.length()] = 0;
return bytes;
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
@Override
protected boolean chunkMatches(byte chunk, byte[] payload) {
return payload.length >= 1 && payload[0] == G1Constants.CommandId.SET_NOTIFICATION_APP_SETTINGS.id;
}
@Override
protected void writeHeader(byte currentChunk, byte chunkCount, byte[] chunk) {
chunk[0] = G1Constants.CommandId.SET_NOTIFICATION_APP_SETTINGS.id;
chunk[1] = chunkCount;
chunk[2] = currentChunk;
}
@Override
protected int getHeaderSize() {
return 3;
}
@Override
public String getName() {
return "set_notification_app_settings";
}
}
public static class CommandSendNotification extends ChunkedCommandHandler {
private final int messageId;
public CommandSendNotification(Consumer<CommandHandler> sendCallback, NotificationSpec notificationSpec) {
super(sendCallback, null, generatePayload(notificationSpec));
this.messageId = notificationSpec.getId();
}
private static byte[] generatePayload(NotificationSpec notificationSpec) {
try {
JSONObject notificationJson = new JSONObject();
notificationJson.put("msg_id", notificationSpec.getId());
notificationJson.put("action", 0);
notificationJson.put("app_identifier", notificationSpec.sourceAppId.substring(0,Math.min(notificationSpec.sourceAppId.length(), 31)));
if (notificationSpec.title != null) notificationJson.put("title", notificationSpec.title);
if (notificationSpec.subject != null) notificationJson.put("subtitle", notificationSpec.subject);
if (notificationSpec.body != null) notificationJson.put("message", notificationSpec.body);
notificationJson.put("time_s", notificationSpec.when / 1000);
notificationJson.put("date", new Date(notificationSpec.when).toString());
notificationJson.put("display_name", notificationSpec.sourceName);
JSONObject json = new JSONObject();
json.put("ncs_notification", notificationJson);
// Need to allocate one larger in order to null terminate.
String jsonString = json.toString();
byte[] bytes = new byte[jsonString.length() + 1];
System.arraycopy(jsonString.getBytes(StandardCharsets.US_ASCII), 0, bytes, 0, jsonString.length());
bytes[jsonString.length()] = 0;
return bytes;
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
@Override
protected boolean chunkMatches(byte chunk, byte[] payload) {
return payload.length >= 1 && payload[0] == G1Constants.CommandId.SEND_NOTIFICATION.id;
}
@Override
protected void writeHeader(byte currentChunk, byte chunkCount, byte[] chunk) {
chunk[0] = G1Constants.CommandId.SEND_NOTIFICATION.id;
chunk[1] = 0x0;
chunk[2] = chunkCount;
chunk[3] = currentChunk;
}
@Override
protected int getHeaderSize() {
return 4;
}
@Override
public String getName() {
return "send_notification_" + messageId;
}
}
public static class CommandSendClearNotification extends CommandHandler {
private final int messageId;
public CommandSendClearNotification(int messageId) {
super(true, null);
this.messageId = messageId;
}
@Override
public byte[] serialize() {
byte[] packet = new byte[] {
G1Constants.CommandId.SEND_CLEAR_NOTIFICATION.id,
0x0,0x0,0x0,0x0
};
BLETypeConversions.writeUint32BE(packet, 1, messageId);
return packet;
}
@Override
public boolean responseMatches(byte[] payload) {
return payload.length >= 1 && payload[0] == G1Constants.CommandId.SEND_CLEAR_NOTIFICATION.id;
}
@Override
public String getName() {
return "send_clear_notification_" + messageId;
}
}
@@ -1,5 +1,7 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.evenrealities;
import android.util.Pair;
import java.util.UUID;
public class G1Constants {
@@ -10,12 +12,26 @@ public class G1Constants {
public static final UUID UUID_CHARACTERISTIC_NORDIC_UART_RX =
UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
public static final int MTU = 251;
// The MTU is set to 251, which suggests there should be a larger packet size, however,
// (excluding headers) the glasses only ever send a payload of 180 bytes.
// TODO: Try out a larger MTU and a larger packet size to see if these numbers are flexible.
// It could be that a 180 byte buffer is allocated in the FW for the payload and sending
// more will cause the glasses to crash.
public static final int MAX_PACKET_SIZE_BYTES = 180;
public static final int HEART_BEAT_DELAY_MS = 28000;
public static final int DEFAULT_COMMAND_TIMEOUT_MS = 5000;
public static final int DISPLAY_SETTINGS_PREVIEW_DELAY = 3000;
public static final int DEFAULT_RETRY_COUNT = 5;
public static final int CASE_BATTERY_INDEX = 2;
public static final String INTENT_TOGGLE_SILENT_MODE = "nodomain.freeyourgadget.gadgetbridge.evenrealities.silent_mode";
// The glasses have a filter based on a whitelist of apps and it will only display
// notifications from apps that are on that list. GadgetBridge already filters the
// notifications before sending to the glasses and GadgetBridge can work as either a
// blacklist or a whitelist. To get around this, a fixed application id is used since the
// glasses don't display it, it doesn't matter to the user experience and it allows all of
// the notification filtering to happen on the phone side.
public static final Pair<String, String>
FIXED_NOTIFICATION_APP_ID = new Pair<>("nodomain.freeyourgadget.gadget", "Name");
// Extract the L or R at the end of the device prefix.
public static Side getSideFromFullName(String deviceName) {
@@ -90,7 +106,6 @@ public class G1Constants {
// TODO: Lifted these from a different project, some of them are wrong.
public enum CommandId {
NOTIFICATION_CONFIG((byte) 0x04),
DASHBOARD_CONFIG((byte) 0x06),
SYNC_SEQUENCE((byte) 0x22), // 0x05
DASHBOARD_SHOWN((byte) 0x22), // 0x0A
@@ -112,7 +127,12 @@ public class G1Constants {
SET_BRIGHTNESS_SETTINGS((byte) 0x01),
GET_WEAR_DETECTION_SETTINGS((byte) 0x3A),
SET_WEAR_DETECTION_SETTINGS((byte) 0x27),
GET_SERIAL_NUMBER((byte) 0x34);
GET_SERIAL_NUMBER((byte) 0x34),
GET_NOTIFICATION_DISPLAY_SETTINGS((byte) 0x3C),
SET_NOTIFICATION_DISPLAY_SETTINGS((byte) 0x4F),
SET_NOTIFICATION_APP_SETTINGS((byte) 0x04),
SEND_NOTIFICATION((byte) 0x4B),
SEND_CLEAR_NOTIFICATION((byte) 0x4C);
final public byte id;
@@ -8,7 +8,8 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Handler;
import android.os.Looper;
import android.os.HandlerThread;
import android.os.Process;
import android.widget.Toast;
import androidx.annotation.Nullable;
@@ -21,18 +22,17 @@ import java.util.ArrayList;
import java.util.Calendar;
import java.util.TimeZone;
import java.util.concurrent.Callable;
import java.util.function.Function;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.Logging;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEMultiDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
@@ -50,7 +50,8 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
*/
public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(G1DeviceSupport.class);
private final Handler backgroundTasksHandler = new Handler(Looper.getMainLooper());
private final HandlerThread backgroundThread = new HandlerThread("even_g1_background_thread", Process.THREAD_PRIORITY_DEFAULT);
private Handler backgroundTasksHandler = null;
private BroadcastReceiver intentReceiver = null;
private final Object lensSkewLock = new Object();
private G1SideManager leftSide = null;
@@ -98,6 +99,11 @@ public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
super.setContext(device, btAdapter, context);
}
if (backgroundTasksHandler == null) {
backgroundThread.start();
backgroundTasksHandler = new Handler(backgroundThread.getLooper());
}
// Register to receive silent mode intent calls from the UI.
if (intentReceiver == null) {
intentReceiver = new IntentReceiver();
@@ -191,8 +197,14 @@ public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
@Override
public void dispose() {
synchronized (ConnectionMonitor) {
// Remove all background tasks.
backgroundTasksHandler.removeCallbacksAndMessages(null);
if (backgroundTasksHandler != null) {
// Remove all background tasks.
backgroundTasksHandler.removeCallbacksAndMessages(null);
// Shutdown the background handler.
backgroundThread.quitSafely();
backgroundTasksHandler = null;
}
// Kill both sides.
leftSide = null;
@@ -223,20 +235,18 @@ public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
// support and we don't want a hard dependency on G1DeviceSupport in G1SideManager.
Callable<BtLEQueue> getQueue = () -> this.getQueue(deviceIdx);
Callable<GBDevice> getDevice = () -> this.getDevice(deviceIdx);
Function<GBDeviceEvent, Void> handleEvent = (GBDeviceEvent event) -> {
this.evaluateGBDeviceEvent(event);
return null;
};
// Create the desired side.
if (deviceIdx == G1Constants.Side.LEFT.getDeviceIndex()) {
leftSide = new G1SideManager(G1Constants.Side.LEFT, backgroundTasksHandler, getQueue,
getDevice, handleEvent, this::getDevicePrefs, rx, tx,
getDevice, this::evaluateGBDeviceEvent,
this::getDevicePrefs, rx, tx,
this::createTransactionBuilder);
return leftSide;
} else if (deviceIdx == G1Constants.Side.RIGHT.getDeviceIndex()) {
rightSide = new G1SideManager(G1Constants.Side.RIGHT, backgroundTasksHandler, getQueue,
getDevice, handleEvent, this::getDevicePrefs, rx, tx,
getDevice, this::evaluateGBDeviceEvent,
this::getDevicePrefs, rx, tx,
this::createTransactionBuilder);
return rightSide;
}
@@ -340,8 +350,13 @@ public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
if (leftSide == null || rightSide == null)
return;
boolean use12HourFormat = getDevicePrefs().getTimeFormat()
.equals(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_12H);
// In FW v1.6.0, they flipped this boolean.
boolean use12HourFormat =
getDevicePrefs().getTimeFormat()
.equals(getDevice().getFirmwareVersion().compareTo("1.6.0") >= 0
? DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H
: DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_12H);
Calendar c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
long currentMilliseconds = c.getTimeInMillis();
long tzOffset = TimeZone.getDefault().getOffset(currentMilliseconds);
@@ -443,4 +458,17 @@ public class G1DeviceSupport extends AbstractBTLEMultiDeviceSupport {
public void onSetTime() {
onSetTimeOrWeather();
}
@Override
public void onNotification(NotificationSpec notificationSpec) {
// Rewrite the App Id to the fixed one used for all notifications. See the comment in
// G1Constants.java for more information.
notificationSpec.sourceAppId = G1Constants.FIXED_NOTIFICATION_APP_ID.first;
leftSide.send(new G1Communications.CommandSendNotification(leftSide::send, notificationSpec));
}
@Override
public void onDeleteNotification(int id) {
leftSide.send(new G1Communications.CommandSendClearNotification(id));
}
}
@@ -1,7 +1,8 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.evenrealities;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.SharedPreferences;
import android.icu.util.Calendar;
import android.icu.util.TimeZone;
import android.os.Build;
import android.os.Handler;
@@ -10,9 +11,11 @@ import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
@@ -21,6 +24,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryIncrementalInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
@@ -42,7 +46,7 @@ public class G1SideManager {
private final Handler backgroundTasksHandler;
private final Callable<BtLEQueue> getQueueHandler;
private final Callable<GBDevice> getDeviceHandler;
private final Function<GBDeviceEvent, Void> sendEventHandler;
private final Consumer<GBDeviceEvent> sendEventHandler;
private final Callable<DevicePrefs> getPrefsHandler;
private final BiFunction<String, Integer, TransactionBuilder> createTransactionBuilder;
private final BluetoothGattCharacteristic rx;
@@ -55,10 +59,11 @@ public class G1SideManager {
private boolean isSilentModeEnabled;
private GBDevice.State connectingState;
private boolean debugEnabled;
private long lastHeartBeatTime;
public G1SideManager(G1Constants.Side mySide, Handler backgroundTasksHandler,
Callable<BtLEQueue> getQueue, Callable<GBDevice> getDevice,
Function<GBDeviceEvent, Void> sendEvent, Callable<DevicePrefs> getPrefs,
Consumer<GBDeviceEvent> sendEvent, Callable<DevicePrefs> getPrefs,
BluetoothGattCharacteristic rx, BluetoothGattCharacteristic tx,
BiFunction<String, Integer, TransactionBuilder> createTransactionBuilder) {
this.mySide = mySide;
@@ -76,9 +81,13 @@ public class G1SideManager {
};
this.heartBeatRunner = () -> {
Calendar c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
long currentMilliseconds = c.getTimeInMillis();
LOG.info("Side {}: {}ms since the last heartbeat", mySide, currentMilliseconds - lastHeartBeatTime);
lastHeartBeatTime = currentMilliseconds;
if (getDevice().isConnected()) {
// We can send any command as a heart beat. The official app uses this one.
send(new G1Communications.CommandGetSilentModeSettings(null));
send(new G1Communications.CommandSendHeartBeat(getNextSequence()));
scheduleHeatBeat();
} else {
// Don't reschedule if the device is disconnected.
@@ -101,6 +110,9 @@ public class G1SideManager {
this.isSilentModeEnabled = false;
this.connectingState = GBDevice.State.CONNECTED;
this.debugEnabled = false;
Calendar c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
this.lastHeartBeatTime = c.getTimeInMillis();
}
private BtLEQueue getQueue() {
@@ -120,7 +132,7 @@ public class G1SideManager {
}
private void evaluateGBDeviceEvent(GBDeviceEvent event) {
sendEventHandler.apply(event);
sendEventHandler.accept(event);
}
private DevicePrefs getDevicePrefs() {
@@ -171,10 +183,15 @@ public class G1SideManager {
postInitializeCommon(transaction);
// These can be sent to both, but the left lens is used as the master for these settings.
sendInTransaction(transaction, new G1Communications.CommandGetDisplaySettings(this::handleDisplaySettingsPayload));
sendInTransaction(transaction, new G1Communications.CommandGetBrightnessSettings(this::handleBrightnessSettingsPayload));
sendInTransaction(transaction, new G1Communications.CommandGetSerialNumber(this::handleSerialNumberPayload));
transaction.queue();
// Sent to the left only and it's own transaction, this is a large piece of data and can
// cause GB to time out the initialization and get stuck in a loop.
send(new G1Communications.CommandSetAppNotificationSettings(
this::send, List.of(G1Constants.FIXED_NOTIFICATION_APP_ID),
false, false, false));
}
public void postInitializeRight() {
@@ -186,7 +203,9 @@ public class G1SideManager {
sendInTransaction(transaction, new G1Communications.CommandGetHeadGestureSettings(this::handleHeadGestureSettingsPayload));
// This setting uses the right lens as the master for the setting simply to balance the amount
// of commands being sent to the left vs right.
sendInTransaction(transaction, new G1Communications.CommandGetDisplaySettings(this::handleDisplaySettingsPayload));
sendInTransaction(transaction, new G1Communications.CommandGetWearDetectionSettings(this::handleWearDetectionSettingsPayload));
sendInTransaction(transaction, new G1Communications.CommandGetNotificationDisplaySettings(this::handleNotificationDisplaySettingsPayload));
transaction.queue();
}
@@ -220,7 +239,12 @@ public class G1SideManager {
this.debugEnabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_DEVICE_LOGS_TOGGLE, false);
send(new G1Communications.CommandSetDebugLogSettings(this.debugEnabled));
break;
case DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS:
case DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS_TIMEOUT:
send(new G1Communications.CommandSetNotificationDisplaySettings(
prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS, true),
(byte)prefs.getInt(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS_TIMEOUT, 5)));
break;
}
}
@@ -432,42 +456,53 @@ public class G1SideManager {
return true;
}
private boolean handleDisplaySettingsPayload(byte[] payload) {
SharedPreferences.Editor editor = getDevicePrefs().getPreferences().edit();
editor.putInt(DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_HEIGHT,
G1Communications.CommandGetDisplaySettings.getHeight(payload));
GBDeviceEventUpdatePreferences prefsEvent = new GBDeviceEventUpdatePreferences();
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_HEIGHT,
G1Communications.CommandGetDisplaySettings.getHeight(payload));
// Depth is indexed is 1-9, so subtract 1 to map it to the 0-8 of the slider.
editor.putInt(DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_DEPTH,
G1Communications.CommandGetDisplaySettings.getDepth(payload) - 1);
editor.apply();
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_DEPTH,
G1Communications.CommandGetDisplaySettings.getDepth(payload) - 1);
evaluateGBDeviceEvent(prefsEvent);
return true;
}
private boolean handleHeadGestureSettingsPayload(byte[] payload) {
SharedPreferences.Editor editor = getDevicePrefs().getPreferences().edit();
editor.putInt(DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_ACTIVATION_ANGLE,
G1Communications.CommandGetHeadGestureSettings.getActivationAngle(payload));
editor.apply();
GBDeviceEventUpdatePreferences prefsEvent = new GBDeviceEventUpdatePreferences();
prefsEvent.preferences.put(
DeviceSettingsPreferenceConst.PREF_EVEN_REALITIES_SCREEN_ACTIVATION_ANGLE,
G1Communications.CommandGetHeadGestureSettings.getActivationAngle(payload));
evaluateGBDeviceEvent(prefsEvent);
return true;
}
private boolean handleBrightnessSettingsPayload(byte[] payload) {
SharedPreferences.Editor editor = getDevicePrefs().getPreferences().edit();
editor.putBoolean(DeviceSettingsPreferenceConst.PREF_SCREEN_AUTO_BRIGHTNESS,
G1Communications.CommandGetBrightnessSettings.isAutoBrightnessEnabled(payload));
editor.putInt(DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS,
G1Communications.CommandGetBrightnessSettings.getBrightnessLevel(payload));
editor.apply();
GBDeviceEventUpdatePreferences prefsEvent = new GBDeviceEventUpdatePreferences();
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_SCREEN_AUTO_BRIGHTNESS,
G1Communications.CommandGetBrightnessSettings.isAutoBrightnessEnabled(payload));
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS,
G1Communications.CommandGetBrightnessSettings.getBrightnessLevel(payload));
evaluateGBDeviceEvent(prefsEvent);
return true;
}
private boolean handleWearDetectionSettingsPayload(byte[] payload) {
SharedPreferences.Editor editor = getDevicePrefs().getPreferences().edit();
editor.putBoolean(DeviceSettingsPreferenceConst.PREF_WEAR_SENSOR_TOGGLE,
G1Communications.CommandGetWearDetectionSettings.isEnabled(payload));
editor.apply();
GBDeviceEventUpdatePreferences prefsEvent = new GBDeviceEventUpdatePreferences();
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_WEAR_SENSOR_TOGGLE,
G1Communications.CommandGetWearDetectionSettings.isEnabled(payload));
evaluateGBDeviceEvent(prefsEvent);
return true;
}
private boolean handleNotificationDisplaySettingsPayload(byte[] payload) {
GBDeviceEventUpdatePreferences prefsEvent = new GBDeviceEventUpdatePreferences();
prefsEvent.preferences.put(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS,
G1Communications.CommandGetNotificationDisplaySettings.isEnabled(payload));
prefsEvent.preferences.put(
DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS_TIMEOUT,
Integer.toString(G1Communications.CommandGetNotificationDisplaySettings.getTimeout(payload)));
evaluateGBDeviceEvent(prefsEvent);
return true;
}
private boolean handleDeviceEventPayload(byte[] payload) {
switch (G1Communications.DeviceEvent.getEventId(payload)) {
case G1Constants.DeviceEventId.GLASSES_CHARGING:
+3 -1
View File
@@ -390,7 +390,9 @@
<string name="pref_title_autoremove_notifications">Autoremove dismissed notifications</string>
<string name="pref_summary_autoremove_notifications">Notifications are automatically removed from the device when dismissed from the phone</string>
<string name="pref_title_screen_on_on_notifications">Screen On on Notifications</string>
<string name="pref_summary_screen_on_on_notifications">Turn on the band\'s screen when a notification arrives</string>
<string name="pref_summary_screen_on_on_notifications">Turn on the device\'s screen when a notification arrives</string>
<string name="pref_title_screen_on_on_notifications_timeout">Notification Display Time</string>
<string name="pref_summary_screen_on_on_notifications_timeout">Delay %s before turning the display back off after a notification</string>
<string name="pref_title_send_app_notifications">Send notifications</string>
<string name="pref_summary_send_app_notifications">Send app notifications to the device</string>
<string name="pref_title_pebble_privacy_mode">Privacy mode</string>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<ListPreference
android:dependency="screen_on_on_notifications"
android:defaultValue="5"
android:entries="@array/screen_timeout"
android:entryValues="@array/screen_timeout_values"
android:key="screen_on_on_notifications_timeout"
android:icon="@drawable/ic_hourglass_empty"
android:summary="@string/pref_summary_screen_on_on_notifications_timeout"
android:title="@string/pref_title_screen_on_on_notifications_timeout" />
</androidx.preference.PreferenceScreen>
@@ -209,4 +209,4 @@
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen>