/* Copyright (C) 2019-2021 Andreas Shimokawa, Gordon Williams
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . */
package nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP_SIZE;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_GPS_UPDATE;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_GPS_UPDATE_INTERVAL;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_GPS_USE_NETWORK_ONLY;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTENTS;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTERNET_ACCESS;
import static nodomain.freeyourgadget.gadgetbridge.database.DBHelper.getUser;
import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_START;
import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STATUS;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.location.Location;
import android.net.Uri;
import android.os.Build;
import android.util.Base64;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.StringRequest;
import com.android.volley.toolbox.Volley;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.StringReader;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.SimpleTimeZone;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import de.greenrobot.dao.query.QueryBuilder;
import io.wax911.emojify.Emoji;
import io.wax911.emojify.EmojiManager;
import io.wax911.emojify.EmojiUtils;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants;
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncState;
import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncStateDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationManager;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.LocationProviderType;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.util.EmojiConverter;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(BangleJSDeviceSupport.class);
private BluetoothGattCharacteristic rxCharacteristic = null;
private BluetoothGattCharacteristic txCharacteristic = null;
private boolean allowHighMTU = false;
private int mtuSize = 20;
int bangleCommandSeq = 0; // to attempt to stop duplicate packets when sending Local Intents
/// Current line of data received from Bangle.js
private String receivedLine = "";
/// All characters received from Bangle.js for debug purposes (limited to MAX_RECEIVE_HISTORY_CHARS). Can be dumped with 'Fetch Device Debug Logs' from Debug menu
private String receiveHistory = "";
private boolean realtimeHRM = false;
private boolean realtimeStep = false;
/// How often should activity data be sent - in seconds
private int realtimeHRMInterval = 10;
/// Last battery percentage reported (or -1) to help with smoothing reported battery levels
private int lastBatteryPercent = -1;
private final LimitedQueue/*Long*/ mNotificationReplyAction = new LimitedQueue(16);
private boolean gpsUpdateSetup = false;
// this stores the globalUartReceiver (for uart.tx intents)
private BroadcastReceiver globalUartReceiver = null;
// used to make HTTP requests and handle responses
private RequestQueue requestQueue = null;
/// Maximum amount of characters to store in receiveHistory
public static final int MAX_RECEIVE_HISTORY_CHARS = 100000;
/// Used to avoid spamming logs with ACTION_DEVICE_CHANGED messages
static String lastStateString;
// Local Intents - for app manager communication
public static final String BANGLEJS_COMMAND_TX = "banglejs_command_tx";
public static final String BANGLEJS_COMMAND_RX = "banglejs_command_rx";
// Global Intents
private static final String BANGLE_ACTION_UART_TX = "com.banglejs.uart.tx";
public BangleJSDeviceSupport() {
super(LOG);
addSupportedService(BangleJSConstants.UUID_SERVICE_NORDIC_UART);
registerLocalIntents();
registerGlobalIntents();
}
@Override
public void dispose() {
super.dispose();
stopGlobalUartReceiver();
stopLocationUpdate();
stopRequestQueue();
}
private void stopGlobalUartReceiver(){
if(globalUartReceiver != null){
GBApplication.getContext().unregisterReceiver(globalUartReceiver); // remove uart.tx intent listener
}
}
private void stopLocationUpdate() {
if (!gpsUpdateSetup)
return;
LOG.info("Stop location updates");
GBLocationManager.stop(getContext(), this);
gpsUpdateSetup = false;
}
private void stopRequestQueue() {
if (requestQueue != null) {
requestQueue.stop();
}
}
private RequestQueue getRequestQueue() {
if (requestQueue == null) {
requestQueue = Volley.newRequestQueue(getContext());
}
return requestQueue;
}
private void addReceiveHistory(String s) {
receiveHistory += s;
if (receiveHistory.length() > MAX_RECEIVE_HISTORY_CHARS)
receiveHistory = receiveHistory.substring(receiveHistory.length() - MAX_RECEIVE_HISTORY_CHARS);
}
private void registerLocalIntents() {
IntentFilter commandFilter = new IntentFilter();
commandFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
commandFilter.addAction(BANGLEJS_COMMAND_TX);
BroadcastReceiver commandReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case BANGLEJS_COMMAND_TX: {
String data = String.valueOf(intent.getExtras().get("DATA"));
BtLEQueue queue = getQueue();
if (queue==null) {
LOG.warn("BANGLEJS_COMMAND_TX received, but getQueue()==null (state=" + gbDevice.getStateString() + ")");
} else {
try {
TransactionBuilder builder = performInitialized("TX");
uartTx(builder, data);
builder.queue(queue);
} catch (IOException e) {
GB.toast(getContext(), "Error in TX: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
}
break;
}
case GBDevice.ACTION_DEVICE_CHANGED: {
String stateString = (gbDevice!=null ? gbDevice.getStateString():"");
if (!stateString.equals(lastStateString)) {
lastStateString = stateString;
LOG.info("ACTION_DEVICE_CHANGED " + stateString);
addReceiveHistory("\n================================================\nACTION_DEVICE_CHANGED "+stateString+" "+(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss", Locale.US)).format(Calendar.getInstance().getTime())+"\n================================================\n");
}
if (gbDevice!=null && (gbDevice.getState() == GBDevice.State.NOT_CONNECTED || gbDevice.getState() == GBDevice.State.WAITING_FOR_RECONNECT)) {
stopLocationUpdate();
}
}
}
}
};
LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter);
}
private void registerGlobalIntents() {
IntentFilter commandFilter = new IntentFilter();
commandFilter.addAction(BANGLE_ACTION_UART_TX);
globalUartReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case BANGLE_ACTION_UART_TX: {
/* In Tasker:
Action: com.banglejs.uart.tx
Cat: None
Extra: line:Terminal.println(%avariable)
Target: Broadcast Receiver
Variable: Number, Configure on Import, NOT structured, Value set, Nothing Exported, NOT Same as value
*/
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
if (!devicePrefs.getBoolean(PREF_DEVICE_INTENTS, false)) return;
String data = intent.getStringExtra("line");
if (data==null) {
GB.toast(getContext(), "UART TX Intent, but no 'line' supplied", Toast.LENGTH_LONG, GB.ERROR);
return;
}
if (!data.endsWith("\n")) data += "\n";
try {
TransactionBuilder builder = performInitialized("TX");
uartTx(builder, data);
builder.queue(getQueue());
} catch (IOException e) {
GB.toast(getContext(), "Error in TX: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
break;
}
}
}
};
GBApplication.getContext().registerReceiver(globalUartReceiver, commandFilter);
}
@Override
protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
LOG.info("Initializing");
gbDevice.setState(GBDevice.State.INITIALIZING);
gbDevice.sendDeviceUpdateIntent(getContext());
gbDevice.setBatteryThresholdPercent((short) 30);
rxCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_RX);
txCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_TX);
builder.setCallback(this);
builder.notify(rxCharacteristic, true);
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
allowHighMTU = devicePrefs.getBoolean(PREF_ALLOW_HIGH_MTU, true);
// No need to clear active line with Ctrl-C now - firmwares in 2023 auto-clear on connect
Prefs prefs = GBApplication.getPrefs();
if (prefs.getBoolean("datetime_synconconnect", true))
transmitTime(builder);
//sendSettings(builder);
// get version
gbDevice.setState(GBDevice.State.INITIALIZED);
gbDevice.sendDeviceUpdateIntent(getContext());
if (getDevice().getFirmwareVersion() == null) {
getDevice().setFirmwareVersion("N/A");
getDevice().setFirmwareVersion2("N/A");
}
lastBatteryPercent = -1;
LOG.info("Initialization Done");
requestBangleGPSPowerStatus();
return builder;
}
/// Write a string of data, and chunk it up
private void uartTx(TransactionBuilder builder, String str) {
byte[] bytes = str.getBytes(StandardCharsets.ISO_8859_1);
LOG.info("UART TX: " + str);
addReceiveHistory("\n================================================\nSENDING "+str+"\n================================================\n");
// FIXME: somehow this is still giving us UTF8 data when we put images in strings. Maybe JSON.stringify is converting to UTF-8?
for (int i=0;imtuSize) l=mtuSize;
byte[] packet = new byte[l];
System.arraycopy(bytes, i, packet, 0, l);
builder.write(txCharacteristic, packet);
}
}
/// Converts an object to a JSON string. see jsonToString
private String jsonToStringInternal(Object v) {
if (v instanceof String) {
/* Convert a string, escaping chars we can't send over out UART connection */
String s = (String)v;
String json = "\"";
//String rawString = "";
for (int i=0;i='0' && nextCh<='7') json += "\\x0" + ch;
else json += "\\" + ch;
} else if (ch==8) json += "\\b";
else if (ch==9) json += "\\t";
else if (ch==10) json += "\\n";
else if (ch==11) json += "\\v";
else if (ch==12) json += "\\f";
else if (ch==34) json += "\\\""; // quote
else if (ch==92) json += "\\\\"; // slash
else if (ch<32 || ch==127 || ch==173 ||
((ch>=0xC2) && (ch<=0xF4))) // unicode start char range
json += "\\x"+Integer.toHexString((ch&255)|256).substring(1);
else if (ch>255)
json += "\\u"+Integer.toHexString((ch&65535)|65536).substring(1);
else json += s.charAt(i);
}
// if it was less characters to send base64, do that!
if (json.length() > 5+(s.length()*4/3)) {
byte[] bytes = s.getBytes(StandardCharsets.ISO_8859_1);
return "atob(\""+Base64.encodeToString(bytes, Base64.DEFAULT).replaceAll("\n","")+"\")";
}
// for debugging...
//addReceiveHistory("\n---------------------\n"+rawString+"\n---------------------\n");
return json + "\"";
} else if (v instanceof JSONArray) {
JSONArray a = (JSONArray)v;
String json = "[";
for (int i=0;i0) json += ",";
Object o = null;
try {
o = a.get(i);
} catch (JSONException e) {
LOG.warn("jsonToString array error: " + e.getLocalizedMessage());
}
json += jsonToStringInternal(o);
}
return json+"]";
} else if (v instanceof JSONObject) {
JSONObject obj = (JSONObject)v;
String json = "{";
Iterator iter = obj.keys();
while (iter.hasNext()) {
String key = iter.next();
Object o = null;
try {
o = obj.get(key);
} catch (JSONException e) {
LOG.warn("jsonToString object error: " + e.getLocalizedMessage());
}
json += "\""+key+"\":"+jsonToStringInternal(o);
if (iter.hasNext()) json+=",";
}
return json+"}";
} else if (v==null) {
// else int/double/null
return "null";
}
return v.toString();
}
/// Convert a JSON object to a JSON String (NOT 100% JSON compliant)
public String jsonToString(JSONObject jsonObj) {
/* jsonObj.toString() works but breaks char codes>128 (encodes as UTF8?) and also uses
\u0000 when just \0 would do (and so on).
So we do it manually, which can be more compact anyway.
This is JSON-ish, so not exactly as per JSON1 spec but good enough for Espruino.
*/
return jsonToStringInternal(jsonObj);
}
/// Write a JSON object of data
private void uartTxJSON(String taskName, JSONObject json) {
try {
TransactionBuilder builder = performInitialized(taskName);
uartTx(builder, "\u0010GB("+jsonToString(json)+")\n");
builder.queue(getQueue());
} catch (IOException e) {
GB.toast(getContext(), "Error in "+taskName+": " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
}
private void uartTxJSONError(String taskName, String message, String id) {
JSONObject o = new JSONObject();
try {
o.put("t", taskName);
if( id!=null)
o.put("id", id);
o.put("err", message);
} catch (JSONException e) {
GB.toast(getContext(), "uartTxJSONError: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
uartTxJSON(taskName, o);
}
private void handleUartRxLine(String line) {
LOG.info("UART RX LINE: " + line);
if (line.length()==0) return;
if (">Uncaught ReferenceError: \"GB\" is not defined".equals(line))
GB.toast(getContext(), "Gadgetbridge plugin not installed on Bangle.js", Toast.LENGTH_LONG, GB.ERROR);
else if (line.charAt(0)=='{') {
// JSON - we hope!
try {
JSONObject json = new JSONObject(line);
if (json.has("t")) {
handleUartRxJSON(json);
LOG.info("UART RX JSON parsed successfully");
} else
LOG.warn("UART RX JSON parsed but doesn't contain 't' - ignoring");
} catch (JSONException e) {
LOG.info("UART RX JSON parse failure: "+ e.getLocalizedMessage());
GB.toast(getContext(), "Malformed JSON from Bangle.js: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
} else {
LOG.info("UART RX line started with "+(int)line.charAt(0)+" - ignoring");
}
}
private void handleUartRxJSON(JSONObject json) throws JSONException {
String packetType = json.getString("t");
switch (packetType) {
case "info":
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.INFO);
break;
case "warn":
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.WARN);
break;
case "error":
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.ERROR);
break;
case "ver": {
final GBDeviceEventVersionInfo gbDeviceEventVersionInfo = new GBDeviceEventVersionInfo();
if (json.has("fw"))
gbDeviceEventVersionInfo.fwVersion = json.getString("fw");
if (json.has("hw"))
gbDeviceEventVersionInfo.hwVersion = json.getString("hw");
evaluateGBDeviceEvent(gbDeviceEventVersionInfo);
} break;
case "findPhone": {
boolean start = json.has("n") && json.getBoolean("n");
GBDeviceEventFindPhone deviceEventFindPhone = new GBDeviceEventFindPhone();
deviceEventFindPhone.event = start ? GBDeviceEventFindPhone.Event.START : GBDeviceEventFindPhone.Event.STOP;
evaluateGBDeviceEvent(deviceEventFindPhone);
} break;
case "music": {
GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl();
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.valueOf(json.getString("n").toUpperCase());
evaluateGBDeviceEvent(deviceEventMusicControl);
} break;
case "call": {
GBDeviceEventCallControl deviceEventCallControl = new GBDeviceEventCallControl();
deviceEventCallControl.event = GBDeviceEventCallControl.Event.valueOf(json.getString("n").toUpperCase());
evaluateGBDeviceEvent(deviceEventCallControl);
} break;
case "status":
handleBatteryStatus(json);
break;
case "notify" :
handleNotificationControl(json);
break;
case "actfetch":
handleActivityFetch(json);
break;
case "act":
handleActivity(json);
break;
case "http":
handleHttp(json);
break;
case "force_calendar_sync":
handleCalendarSync(json);
break;
case "intent":
handleIntent(json);
break;
case "gps_power": {
boolean status = json.getBoolean("status");
LOG.info("Got gps power status: " + status);
if (status) {
setupGPSUpdateTimer();
} else {
stopLocationUpdate();
}
} break;
default : {
LOG.info("UART RX JSON packet type '"+packetType+"' not understood.");
}
}
}
/**
* Handle "status" packets: battery info updates
*/
private void handleBatteryStatus(JSONObject json) throws JSONException {
GBDeviceEventBatteryInfo batteryInfo = new GBDeviceEventBatteryInfo();
batteryInfo.state = BatteryState.UNKNOWN;
if (json.has("chg")) {
batteryInfo.state = (json.getInt("chg") == 1) ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL;
}
if (json.has("bat")) {
int b = json.getInt("bat");
if (b < 0) b = 0;
if (b > 100) b = 100;
// smooth out battery level reporting (it can only go up if charging, or down if discharging)
// http://forum.espruino.com/conversations/379294
if (lastBatteryPercent<0) lastBatteryPercent = b;
if (batteryInfo.state == BatteryState.BATTERY_NORMAL && b > lastBatteryPercent)
b = lastBatteryPercent;
if (batteryInfo.state == BatteryState.BATTERY_CHARGING && b < lastBatteryPercent)
b = lastBatteryPercent;
lastBatteryPercent = b;
batteryInfo.level = b;
}
if (json.has("volt"))
batteryInfo.voltage = (float) json.getDouble("volt");
handleGBDeviceEvent(batteryInfo);
}
/**
* Handle "notify" packet, used to send notification control from device to GB
*/
private void handleNotificationControl(JSONObject json) throws JSONException {
GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl();
// .title appears unused
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.valueOf(json.getString("n").toUpperCase());
if (json.has("id"))
deviceEvtNotificationControl.handle = json.getInt("id");
if (json.has("tel"))
deviceEvtNotificationControl.phoneNumber = json.getString("tel");
if (json.has("msg"))
deviceEvtNotificationControl.reply = json.getString("msg");
/* REPLY responses don't use the ID from the event (MUTE/etc seem to), but instead
* they use a handle that was provided in an action list on the onNotification.. event */
if (deviceEvtNotificationControl.event == GBDeviceEventNotificationControl.Event.REPLY) {
Long foundHandle = (Long)mNotificationReplyAction.lookup((int)deviceEvtNotificationControl.handle);
if (foundHandle!=null)
deviceEvtNotificationControl.handle = foundHandle;
}
evaluateGBDeviceEvent(deviceEvtNotificationControl);
}
private void handleActivityFetch(final JSONObject json) throws JSONException {
final String state = json.getString("state");
if ("start".equals(state)) {
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data),"", true, 0, getContext());
getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
} else if ("end".equals(state)) {
saveLastSyncTimestamp(System.currentTimeMillis() - 1000L * 60);
getDevice().unsetBusyTask();
GB.updateTransferNotification(null, "", false, 100, getContext());
} else {
LOG.warn("Unknown actfetch state {}", state);
}
final GBDeviceEventUpdatePreferences event = new GBDeviceEventUpdatePreferences()
.withPreference(PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STATUS, state);
evaluateGBDeviceEvent(event);
getDevice().sendDeviceUpdateIntent(getContext());
}
/**
* Handle "act" packet, used to send activity reports
*/
private void handleActivity(JSONObject json) throws JSONException {
BangleJSActivitySample sample = new BangleJSActivitySample();
int timestamp = (int) (json.optLong("ts", System.currentTimeMillis()) / 1000);
int hrm = json.optInt("hrm", 0);
int steps = json.optInt("stp", 0);
int intensity = json.optInt("mov", ActivitySample.NOT_MEASURED);
boolean realtime = json.optInt("rt", 0) == 1;
int activity = BangleJSSampleProvider.TYPE_ACTIVITY;
/*if (json.has("act")) {
String actName = "TYPE_" + json.getString("act").toUpperCase();
try {
Field f = ActivityKind.class.getField(actName);
try {
activity = f.getInt(null);
} catch (IllegalAccessException e) {
LOG.info("JSON activity '"+actName+"' not readable");
}
} catch (NoSuchFieldException e) {
LOG.info("JSON activity '"+actName+"' not found");
}
}*/
sample.setTimestamp(timestamp);
sample.setRawKind(activity);
sample.setHeartRate(hrm);
sample.setSteps(steps);
sample.setRawIntensity(intensity);
if (!realtime) {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
final Long userId = getUser(dbHandler.getDaoSession()).getId();
final Long deviceId = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()).getId();
BangleJSSampleProvider provider = new BangleJSSampleProvider(getDevice(), dbHandler.getDaoSession());
sample.setDeviceId(deviceId);
sample.setUserId(userId);
provider.upsertSample(sample);
} catch (final Exception ex) {
LOG.warn("Error saving activity: " + ex.getLocalizedMessage());
}
}
// push realtime data
if (realtime && (realtimeHRM || realtimeStep)) {
Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample);
LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
}
/**
* Handle "http" packet: make an HTTP request and return a "http" response
*/
private void handleHttp(JSONObject json) throws JSONException {
String _id = null;
try {
_id = json.getString("id");
} catch (JSONException e) {
}
final String id = _id;
if (! BuildConfig.INTERNET_ACCESS) {
uartTxJSONError("http", "Internet access not enabled, check Gadgetbridge Device Settings", id);
return;
}
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
if (! devicePrefs.getBoolean(PREF_DEVICE_INTERNET_ACCESS, false)) {
uartTxJSONError("http", "Internet access not enabled in this Gadgetbridge build", id);
return;
}
String url = json.getString("url");
int method = Request.Method.GET;
if (json.has("method")) {
String m = json.getString("method").toLowerCase();
if (m.equals("get")) method = Request.Method.GET;
else if (m.equals("post")) method = Request.Method.POST;
else if (m.equals("head")) method = Request.Method.HEAD;
else if (m.equals("put")) method = Request.Method.PUT;
else if (m.equals("patch")) method = Request.Method.PATCH;
else if (m.equals("delete")) method = Request.Method.DELETE;
else uartTxJSONError("http", "Unknown HTTP method "+m,id);
}
byte[] _body = null;
if (json.has("body"))
_body = json.getString("body").getBytes();
final byte[] body = _body;
Map _headers = null;
if (json.has("headers")) {
JSONObject h = json.getJSONObject("headers");
_headers = new HashMap();
Iterator iter = h.keys();
while (iter.hasNext()) {
String key = iter.next();
try {
String value = h.getString(key);
_headers.put(key, value);
} catch (JSONException e) {
}
}
}
final Map headers = _headers;
String _xmlPath = "";
String _xmlReturn = "";
try {
_xmlPath = json.getString("xpath");
_xmlReturn = json.getString("return");
} catch (JSONException e) {
}
final String xmlPath = _xmlPath;
final String xmlReturn = _xmlReturn;
// Request a string response from the provided URL.
StringRequest stringRequest = new StringRequest(method, url,
new Response.Listener() {
@Override
public void onResponse(String response) {
JSONObject o = new JSONObject();
if (xmlPath.length() != 0) {
try {
InputSource inputXML = new InputSource(new StringReader(response));
XPath xPath = XPathFactory.newInstance().newXPath();
if (xmlReturn.equals("array")) {
NodeList result = (NodeList) xPath.evaluate(xmlPath, inputXML, XPathConstants.NODESET);
response = null; // don't add it below
JSONArray arr = new JSONArray();
if (result != null) {
for (int i = 0; i < result.getLength(); i++)
arr.put(result.item(i).getTextContent());
}
o.put("resp", arr);
} else {
response = xPath.evaluate(xmlPath, inputXML);
}
} catch (Exception error) {
uartTxJSONError("http", error.toString(), id);
return;
}
}
try {
o.put("t", "http");
if( id!=null)
o.put("id", id);
if (response!=null)
o.put("resp", response);
} catch (JSONException e) {
GB.toast(getContext(), "HTTP: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
uartTxJSON("http", o);
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
uartTxJSONError("http", error.toString(), id);
}
}) {
@Override
public byte[] getBody() throws AuthFailureError {
if (body == null) return super.getBody();
return body;
}
@Override
public Map getHeaders() throws AuthFailureError {
// clone the data from super.getHeaders() so we can write to it
Map h = new HashMap<>(super.getHeaders());
if (headers != null) {
for (String key : headers.keySet()) {
String value = headers.get(key);
h.put(key, value);
}
}
return h;
}
};
RequestQueue queue = getRequestQueue();
queue.add(stringRequest);
}
/**
* Handle "force_calendar_sync" packet
*/
private void handleCalendarSync(JSONObject json) throws JSONException {
//if(!GBApplication.getPrefs().getBoolean("enable_calendar_sync", false)) return;
//pretty much like the updateEvents in CalendarReceiver, but would need a lot of libraries here
JSONArray ids = json.getJSONArray("ids");
ArrayList idsList = new ArrayList<>(ids.length());
try (DBHandler dbHandler = GBApplication.acquireDB()) {
DaoSession session = dbHandler.getDaoSession();
Long deviceId = DBHelper.getDevice(gbDevice, session).getId();
QueryBuilder qb = session.getCalendarSyncStateDao().queryBuilder();
//FIXME just use that and don't query every time?
List states = qb.where(
CalendarSyncStateDao.Properties.DeviceId.eq(deviceId)).build().list();
LOG.info("force_calendar_sync on banglejs: "+ ids.length() +" events on the device, "+ states.size() +" on our db");
for (int i = 0; i < ids.length(); i++) {
Long id = ids.getLong(i);
qb = session.getCalendarSyncStateDao().queryBuilder(); //is this needed again?
CalendarSyncState calendarSyncState = qb.where(
qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId),
CalendarSyncStateDao.Properties.CalendarEntryId.eq(id))).build().unique();
if(calendarSyncState == null) {
onDeleteCalendarEvent((byte)0, id);
LOG.info("event id="+ id +" is on device id="+ deviceId +", removing it there");
} else {
//used for later, no need to check twice the ones that do not match
idsList.add(id);
}
}
//remove all elements not in ids from database (we don't have them)
for(CalendarSyncState calendarSyncState : states) {
long id = calendarSyncState.getCalendarEntryId();
if(!idsList.contains(id)) {
qb = session.getCalendarSyncStateDao().queryBuilder(); //is this needed again?
qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId),
CalendarSyncStateDao.Properties.CalendarEntryId.eq(id)))
.buildDelete().executeDeleteWithoutDetachingEntities();
LOG.info("event id="+ id +" is not on device id="+ deviceId +", removing from our db");
}
}
} catch (Exception e1) {
GB.toast("Database Error while forcefully syncing Calendar", Toast.LENGTH_SHORT, GB.ERROR, e1);
}
//force a syncCalendar now, send missing events
CalendarReceiver.forceSync();
}
/**
* Handle "intent" packet: broadcast an Android intent
*/
private void handleIntent(JSONObject json) throws JSONException {
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
if (!devicePrefs.getBoolean(PREF_DEVICE_INTENTS, false)) {
uartTxJSONError("intent", "Android Intents not enabled, check Gadgetbridge Device Settings", null);
return;
}
String target = json.has("target") ? json.getString("target") : "broadcastreceiver";
Intent in = new Intent();
if (json.has("action")) in.setAction(json.getString("action"));
if (json.has("flags")) {
JSONArray flags = json.getJSONArray("flags");
for (int i = 0; i < flags.length(); i++) {
in = addIntentFlag(in, flags.getString(i));
}
}
if (json.has("categories")) {
JSONArray categories = json.getJSONArray("categories");
for (int i = 0; i < categories.length(); i++) {
in.addCategory(categories.getString(i));
}
}
if (json.has("package") && !json.has("class")) {
in = json.getString("package").equals("gadgetbridge") ?
in.setPackage(this.getContext().getPackageName()) :
in.setPackage(json.getString("package"));
}
if (json.has("package") && json.has("class")) {
in = json.getString("package").equals("gadgetbridge") ?
in.setClassName(this.getContext().getPackageName(), json.getString("class")) :
in.setClassName(json.getString("package"), json.getString("class"));
}
if (json.has("mimetype")) in.setType(json.getString("mimetype"));
if (json.has("data")) in.setData(Uri.parse(json.getString("data")));
if (json.has("extra")) {
JSONObject extra = json.getJSONObject("extra");
Iterator iter = extra.keys();
while (iter.hasNext()) {
String key = iter.next();
in.putExtra(key, extra.getString(key)); // Should this be implemented for other types, e.g. extra.getInt(key)? Or will this always work even if receiving ints/doubles/etc.?
}
}
LOG.info("Executing intent:\n\t" + String.valueOf(in) + "\n\tTargeting: " + target);
//GB.toast(getContext(), String.valueOf(in), Toast.LENGTH_LONG, GB.INFO);
switch (target) {
case "broadcastreceiver":
getContext().sendBroadcast(in);
break;
case "activity": // See wakeActivity.java if you want to start activities from under the keyguard/lock sceen.
getContext().startActivity(in);
break;
case "service": // Should this be implemented differently, e.g. workManager?
getContext().startService(in);
break;
case "foregroundservice": // Should this be implemented differently, e.g. workManager?
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getContext().startForegroundService(in);
} else {
getContext().startService(in);
}
break;
default:
LOG.info("Targeting '"+target+"' isn't implemented or doesn't exist.");
GB.toast(getContext(), "Targeting '"+target+"' isn't implemented or it doesn't exist.", Toast.LENGTH_LONG, GB.INFO);
}
}
private Intent addIntentFlag(Intent intent, String flag) {
try {
final Class intentClass = Intent.class;
final Field flagField = intentClass.getDeclaredField(flag);
intent.addFlags(flagField.getInt(null));
} catch (final Exception e) {
// The user sent an invalid flag
LOG.info("Flag '"+flag+"' isn't implemented or doesn't exist and was therefore not set.");
GB.toast(getContext(), "Flag '"+flag+"' isn't implemented or it doesn't exist and was therefore not set.", Toast.LENGTH_LONG, GB.INFO);
}
return intent;
}
@Override
public void onSendConfiguration(final String config) {
switch (config) {
case PREF_BANGLEJS_ACTIVITY_FULL_SYNC_START:
fetchActivityData(0);
return;
}
LOG.warn("Unknown config changed: {}", config);
}
@Override
public boolean onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
if (super.onCharacteristicChanged(gatt, characteristic)) {
return true;
}
if (BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_RX.equals(characteristic.getUuid())) {
byte[] chars = characteristic.getValue();
// check to see if we get more data - if so, increase out MTU for sending
if (allowHighMTU && chars.length > mtuSize)
mtuSize = chars.length;
// Scan for flow control characters
for (int i=0;is&&(s.timezone="+tz+",require('Storage').write('setting.json',s)))(require('Storage').readJSON('setting.json',1))";
uartTx(builder, cmd+"\n");
}
void requestBangleGPSPowerStatus() {
try {
JSONObject o = new JSONObject();
o.put("t", "is_gps_active");
LOG.debug("Requesting gps power status: " + o.toString());
uartTxJSON("is_gps_active", o);
} catch (JSONException e) {
GB.toast(getContext(), "uartTxJSONError: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
}
void setupGPSUpdateTimer() {
if (gpsUpdateSetup) {
LOG.debug("GPS position timer is already setup");
return;
}
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()));
if(devicePrefs.getBoolean(PREF_DEVICE_GPS_UPDATE, false)) {
int intervalLength = devicePrefs.getInt(PREF_DEVICE_GPS_UPDATE_INTERVAL, 1000);
LOG.info("Setup location listener with an update interval of " + intervalLength + " ms");
boolean onlyUseNetworkGPS = devicePrefs.getBoolean(PREF_DEVICE_GPS_USE_NETWORK_ONLY, false);
LOG.info("Using combined GPS and NETWORK based location: " + onlyUseNetworkGPS);
if (!onlyUseNetworkGPS) {
try {
GBLocationManager.start(getContext(), this, LocationProviderType.GPS, intervalLength);
} catch (IllegalArgumentException e) {
LOG.warn("GPS provider could not be started", e);
}
}
try {
GBLocationManager.start(getContext(), this, LocationProviderType.NETWORK, intervalLength);
} catch (IllegalArgumentException e) {
LOG.warn("NETWORK provider could not be started", e);
}
} else {
GB.toast("Phone gps data update is deactivated in the settings", Toast.LENGTH_SHORT, GB.INFO);
}
gpsUpdateSetup = true;
}
@Override
public void onSetGpsLocation(final Location location) {
if (!GBApplication.getPrefs().getBoolean("use_updated_location_if_available", false)) return;
LOG.debug("new location: " + location.toString());
JSONObject o = new JSONObject();
try {
o.put("t", "gps");
o.put("lat", location.getLatitude());
o.put("lon", location.getLongitude());
o.put("alt", location.getAltitude());
o.put("speed", location.getSpeed());
if (location.hasBearing()) o.put("course", location.getBearing());
o.put("time", new Date().getTime());
if (location.getExtras() != null) {
LOG.debug("Found number of satellites: " + location.getExtras().getInt("satellites", -1));
o.put("satellites",location.getExtras().getInt("satellites"));
} else {
o.put("satellites", 0);
}
o.put("hdop", location.getAccuracy());
o.put("externalSource", true);
o.put("gpsSource", location.getProvider());
LOG.debug("Sending gps value: " + o.toString());
uartTxJSON("gps", o);
} catch (JSONException e) {
GB.toast(getContext(), "uartTxJSONError: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
}
}
@Override
public boolean useAutoConnect() {
return true;
}
private String renderUnicodeWordAsImage(String word) {
// check for emoji
boolean hasEmoji = false;
if (EmojiUtils.getAllEmojis()==null)
EmojiManager.initEmojiData(GBApplication.getContext());
for(Emoji emoji : EmojiUtils.getAllEmojis())
if (word.contains(emoji.getEmoji())) {
hasEmoji = true;
break;
}
// if we had emoji, ensure we create 3 bit color (not 1 bit B&W)
return "\0"+bitmapToEspruinoString(textToBitmap(word), hasEmoji ? BangleJSBitmapStyle.RGB_3BPP_TRANSPARENT : BangleJSBitmapStyle.MONOCHROME_TRANSPARENT);
}
public String renderUnicodeAsImage(String txt) {
// FIXME: it looks like we could implement this as customStringFilter now so it happens automatically
if (txt==null) return null;
// Simple conversions
txt = txt.replaceAll("…", "...");
/* If we're not doing conversion, pass this right back (we use the EmojiConverter
As we would have done if BangleJSCoordinator.supportsUnicodeEmojis had reported false */
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
if (!devicePrefs.getBoolean(PREF_BANGLEJS_TEXT_BITMAP, false))
return EmojiConverter.convertUnicodeEmojiToAscii(txt, GBApplication.getContext());
// Otherwise split up and check each word
String word = "", result = "";
boolean needsTranslate = false;
for (int i=0;i=0) {
// word split
if (needsTranslate) { // convert word
LOG.info("renderUnicodeAsImage converting " + word);
result += renderUnicodeWordAsImage(word)+ch;
} else { // or just copy across
result += word+ch;
}
word = "";
needsTranslate = false;
} else {
// TODO: better check?
if (ch>255) needsTranslate = true;
word += ch;
}
}
if (needsTranslate) { // convert word
LOG.info("renderUnicodeAsImage converting " + word);
result += renderUnicodeWordAsImage(word);
} else { // or just copy across
result += word;
}
return result;
}
/// Crop a text string to ensure it's not longer than requested
public String cropToLength(String txt, int len) {
if (txt==null) return "";
if (txt.length()<=len) return txt;
return txt.substring(0,len-3)+"...";
}
@Override
public void onNotification(NotificationSpec notificationSpec) {
if (notificationSpec.attachedActions!=null)
for (int i=0;i alarms) {
try {
JSONObject o = new JSONObject();
o.put("t", "alarm");
JSONArray jsonalarms = new JSONArray();
o.put("d", jsonalarms);
for (Alarm alarm : alarms) {
if (alarm.getUnused()) continue;
JSONObject jsonalarm = new JSONObject();
jsonalarms.put(jsonalarm);
//Calendar calendar = AlarmUtils.toCalendar(alarm);
jsonalarm.put("h", alarm.getHour());
jsonalarm.put("m", alarm.getMinute());
jsonalarm.put("rep", alarm.getRepetition());
jsonalarm.put("on", alarm.getEnabled());
}
uartTxJSON("onSetAlarms", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetCallState(CallSpec callSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "call");
String cmdName = "";
try {
Field[] fields = callSpec.getClass().getDeclaredFields();
for (Field field : fields)
if (field.getName().startsWith("CALL_") && field.getInt(callSpec) == callSpec.command)
cmdName = field.getName().substring(5).toLowerCase();
} catch (IllegalAccessException e) {}
o.put("cmd", cmdName);
o.put("name", renderUnicodeAsImage(callSpec.name));
o.put("number", callSpec.number);
uartTxJSON("onSetCallState", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetMusicState(MusicStateSpec stateSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "musicstate");
int musicState = stateSpec.state;
String[] musicStates = {"play", "pause", "stop", ""};
if (musicState<0) musicState=3;
if (musicState>=musicStates.length) musicState = musicStates.length-1;
o.put("state", musicStates[musicState]);
o.put("position", stateSpec.position);
o.put("shuffle", stateSpec.shuffle);
o.put("repeat", stateSpec.repeat);
uartTxJSON("onSetMusicState", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetMusicInfo(MusicSpec musicSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "musicinfo");
o.put("artist", renderUnicodeAsImage(musicSpec.artist));
o.put("album", renderUnicodeAsImage(musicSpec.album));
o.put("track", renderUnicodeAsImage(musicSpec.track));
o.put("dur", musicSpec.duration);
o.put("c", musicSpec.trackCount);
o.put("n", musicSpec.trackNr);
uartTxJSON("onSetMusicInfo", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
private void transmitActivityStatus() {
try {
JSONObject o = new JSONObject();
o.put("t", "act");
o.put("hrm", realtimeHRM);
o.put("stp", realtimeStep);
o.put("int", realtimeHRMInterval);
uartTxJSON("onEnableRealtimeSteps", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onEnableRealtimeSteps(boolean enable) {
if (enable == realtimeHRM) return;
realtimeStep = enable;
transmitActivityStatus();
}
@Override
public void onFetchRecordedData(int dataTypes) {
if ((dataTypes & RecordedDataTypes.TYPE_ACTIVITY) != 0) {
fetchActivityData(getLastSuccessfulSyncTime());
}
if ((dataTypes & RecordedDataTypes.TYPE_DEBUGLOGS) != 0) {
File dir;
try {
dir = FileUtils.getExternalFilesDir();
} catch (IOException e) {
return;
}
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US);
String filename = "banglejs_debug_" + dateFormat.format(new Date()) + ".log";
File outputFile = new File(dir, filename );
LOG.warn("Writing log to "+outputFile.toString());
try {
BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile));
writer.write(receiveHistory);
writer.close();
receiveHistory = "";
GB.toast(getContext(), "Log written to "+filename, Toast.LENGTH_LONG, GB.INFO);
} catch (IOException e) {
LOG.warn("Could not write to file", e);
}
}
}
protected void fetchActivityData(final long timestampMillis) {
try {
JSONObject o = new JSONObject();
o.put("t", "actfetch");
o.put("ts", timestampMillis);
uartTxJSON("fetch activity data", o);
} catch (final JSONException e) {
LOG.warn("Failed to fetch activity data", e);
}
}
protected String getLastSyncTimeKey() {
return "lastSyncTimeMillis";
}
protected void saveLastSyncTimestamp(final long timestamp) {
final SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).edit();
editor.putLong(getLastSyncTimeKey(), timestamp);
editor.apply();
}
protected long getLastSuccessfulSyncTime() {
long timeStampMillis = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).getLong(getLastSyncTimeKey(), 0);
if (timeStampMillis != 0) {
return timeStampMillis;
}
final GregorianCalendar calendar = BLETypeConversions.createCalendar();
calendar.add(Calendar.DAY_OF_MONTH, -1);
return calendar.getTimeInMillis();
}
@Override
public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
if (enable == realtimeHRM) return;
realtimeHRM = enable;
transmitActivityStatus();
}
@Override
public void onFindDevice(boolean start) {
try {
JSONObject o = new JSONObject();
o.put("t", "find");
o.put("n", start);
uartTxJSON("onFindDevice", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetConstantVibration(int integer) {
try {
JSONObject o = new JSONObject();
o.put("t", "vibrate");
o.put("n", integer);
uartTxJSON("onSetConstantVibration", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetHeartRateMeasurementInterval(int seconds) {
realtimeHRMInterval = seconds;
transmitActivityStatus();
}
@Override
public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "calendar");
o.put("id", calendarEventSpec.id);
o.put("type", calendarEventSpec.type); //implement this too? (sunrise and set)
o.put("timestamp", calendarEventSpec.timestamp);
o.put("durationInSeconds", calendarEventSpec.durationInSeconds);
o.put("title", renderUnicodeAsImage(cropToLength(calendarEventSpec.title,40)));
o.put("description", renderUnicodeAsImage(cropToLength(calendarEventSpec.description,200)));
o.put("location", renderUnicodeAsImage(cropToLength(calendarEventSpec.location,40)));
o.put("calName", cropToLength(calendarEventSpec.calName,20));
o.put("color", calendarEventSpec.color);
o.put("allDay", calendarEventSpec.allDay);
uartTxJSON("onAddCalendarEvent", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onDeleteCalendarEvent(byte type, long id) {
try {
JSONObject o = new JSONObject();
o.put("t", "calendar-");
o.put("id", id);
uartTxJSON("onDeleteCalendarEvent", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSendWeather(WeatherSpec weatherSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "weather");
o.put("temp", weatherSpec.currentTemp);
o.put("hi", weatherSpec.todayMaxTemp);
o.put("lo", weatherSpec.todayMinTemp );
o.put("hum", weatherSpec.currentHumidity);
o.put("rain", weatherSpec.precipProbability);
o.put("uv", Math.round(weatherSpec.uvIndex*10)/10);
o.put("code", weatherSpec.currentConditionCode);
o.put("txt", weatherSpec.currentCondition);
o.put("wind", weatherSpec.windSpeed);
o.put("wdir", weatherSpec.windDirection);
o.put("loc", weatherSpec.location);
uartTxJSON("onSendWeather", o);
} catch (JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
public Bitmap textToBitmap(String text) {
Paint paint = new Paint(0); // Paint.ANTI_ALIAS_FLAG not wanted as 1bpp
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
paint.setTextSize(devicePrefs.getInt(PREF_BANGLEJS_TEXT_BITMAP_SIZE, 18));
paint.setColor(0xFFFFFFFF);
paint.setTextAlign(Paint.Align.LEFT);
float baseline = -paint.ascent(); // ascent() is negative
int width = (int) (paint.measureText(text) + 0.5f); // round
int height = (int) (baseline + paint.descent() + 0.5f);
if (width<1) width=1;
if (height<1) height=1;
Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(image);
canvas.drawText(text, 0, baseline, paint);
return image;
}
public enum BangleJSBitmapStyle {
MONOCHROME, // 1bpp
MONOCHROME_TRANSPARENT, // 1bpp, black = transparent
RGB_3BPP, // 3bpp
RGB_3BPP_TRANSPARENT // 3bpp, least used color as transparent
}
/** Used for writing single bits to an array */
public static class BitWriter {
int n;
final byte[] bits;
int currentByte, bitIdx;
public BitWriter(byte[] array, int offset) {
bits = array;
n = offset;
}
public void push(boolean v) {
currentByte = (currentByte << 1) | (v?1:0);
bitIdx++;
if (bitIdx == 8) {
bits[n++] = (byte)currentByte;
bitIdx = 0;
currentByte = 0;
}
}
public void finish() {
if (bitIdx > 0) bits[n++] = (byte)currentByte;
}
}
/** Convert an Android bitmap to a base64 string for use in Espruino.
* Currently only 1bpp, no scaling */
public static byte[] bitmapToEspruinoArray(Bitmap bitmap, BangleJSBitmapStyle style) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int bpp = (style==BangleJSBitmapStyle.RGB_3BPP ||
style==BangleJSBitmapStyle.RGB_3BPP_TRANSPARENT) ? 3 : 1;
byte[] pixels = new byte[width * height];
final byte PIXELCOL_TRANSPARENT = -1;
final int[] ditherMatrix = {1*16,5*16,7*16,3*16}; // for bayer dithering
// if doing RGB_3BPP_TRANSPARENT, check image to see if it's transparent
// MONOCHROME_TRANSPARENT is handled later on...
boolean allowTransparency = (style == BangleJSBitmapStyle.RGB_3BPP_TRANSPARENT);
boolean isTransparent = false;
byte transparentColorIndex = 0;
/* Work out what colour index each pixel should be and write to pixels.
Also figure out if we're transparent at all, and how often each color is used */
int[] colUsage = new int[8];
int n = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int pixel = bitmap.getPixel(x, y);
int r = pixel & 255;
int g = (pixel >> 8) & 255;
int b = (pixel >> 16) & 255;
int a = (pixel >> 24) & 255;
boolean pixelTransparent = allowTransparency && (a < 128);
if (pixelTransparent) {
isTransparent = true;
r = g = b = 0;
}
// do dithering here
int ditherAmt = ditherMatrix[(x&1) + (y&1)*2];
r += ditherAmt;
g += ditherAmt;
b += ditherAmt;
int col = 0;
if (bpp==1)
col = ((r+g+b) >= 768)?1:0;
else if (bpp==3)
col = ((r>=256)?1:0) | ((g>=256)?2:0) | ((b>=256)?4:0);
if (!pixelTransparent) colUsage[col]++; // if not transparent, record usage
// save colour, mark transparent separately
pixels[n++] = (byte)(pixelTransparent ? PIXELCOL_TRANSPARENT : col);
}
}
// if we're transparent, find the least-used color, and use that for transparency
if (isTransparent) {
// find least used
int minColUsage = -1;
for (int c=0;c<8;c++) {
if (minColUsage<0 || colUsage[c]> 3) + headerLen];
bmp[0] = (byte)width;
bmp[1] = (byte)height;
bmp[2] = (byte)(bpp + (isTransparent?128:0));
if (isTransparent) bmp[3] = transparentColorIndex;
// Now write the image out bit by bit
BitWriter bits = new BitWriter(bmp, headerLen);
n = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int pixel = pixels[n++];
for (int b=bpp-1;b>=0;b--)
bits.push(((pixel>>b)&1) != 0);
}
}
bits.finish();
return bmp;
}
/** Convert an Android bitmap to a base64 string for use in Espruino.
* Currently only 1bpp, no scaling */
public static String bitmapToEspruinoString(Bitmap bitmap, BangleJSBitmapStyle style) {
return new String(bitmapToEspruinoArray(bitmap, style), StandardCharsets.ISO_8859_1);
}
/** Convert an Android bitmap to a base64 string for use in Espruino.
* Currently only 1bpp, no scaling */
public static String bitmapToEspruinoBase64(Bitmap bitmap, BangleJSBitmapStyle style) {
return Base64.encodeToString(bitmapToEspruinoArray(bitmap, style), Base64.DEFAULT).replaceAll("\n","");
}
/** Convert a drawable to a bitmap, for use with bitmapToEspruino */
public static Bitmap drawableToBitmap(Drawable drawable) {
final int maxWidth = 32;
final int maxHeight = 32;
/* Return bitmap directly but only if it's small enough. It could be
we have a bitmap but it's just too big to send direct to the bangle */
if (drawable instanceof BitmapDrawable) {
BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
Bitmap bmp = bitmapDrawable.getBitmap();
if (bmp != null && bmp.getWidth()<=maxWidth && bmp.getHeight()<=maxHeight)
return bmp;
}
/* Otherwise render this to a bitmap ourselves.. work out size */
int w = maxWidth;
int h = maxHeight;
if (drawable.getIntrinsicWidth() > 0 && drawable.getIntrinsicHeight() > 0) {
w = drawable.getIntrinsicWidth();
h = drawable.getIntrinsicHeight();
// don't allocate anything too big, but keep the ratio
if (w>maxWidth) {
h = h * maxWidth / w;
w = maxWidth;
}
if (h>maxHeight) {
w = w * maxHeight / h;
h = maxHeight;
}
}
/* render */
Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}
/*
* Request the banglejs to send all ids to sync with our database
* TODO perhaps implement a minimum interval between consecutive requests
*/
private void forceCalendarSync() {
try {
JSONObject o = new JSONObject();
o.put("t", "force_calendar_sync_start");
uartTxJSON("forceCalendarSync", o);
} catch(JSONException e) {
LOG.info("JSONException: " + e.getLocalizedMessage());
}
}
@Override
public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) {
try {
JSONObject o = new JSONObject();
o.put("t", "nav");
if (navigationInfoSpec.instruction!=null)
o.put("instr", navigationInfoSpec.instruction);
o.put("distance", navigationInfoSpec.distanceToTurn);
String[] navActions = {
"","continue", "left", "left_slight", "left_sharp", "right", "right_slight",
"right_sharp", "keep_left", "keep_right", "uturn_left", "uturn_right",
"offroute", "roundabout_right", "roundabout_left", "roundabout_straight", "roundabout_uturn", "finish"};
if (navigationInfoSpec.nextAction>0 && navigationInfoSpec.nextAction