/* 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 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.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; 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.Response; import com.android.volley.RequestQueue; 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.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.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.List; import java.util.Iterator; import java.util.Locale; import java.util.Map; import java.util.SimpleTimeZone; import java.util.UUID; import java.lang.reflect.Field; import io.wax911.emojify.Emoji; import io.wax911.emojify.EmojiManager; import io.wax911.emojify.EmojiUtils; import de.greenrobot.dao.query.QueryBuilder; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; 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.devices.banglejs.BangleJSConstants; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSSampleProvider; import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver; 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.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; 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.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; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP_SIZE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTERNET_ACCESS; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTENTS; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP; import static nodomain.freeyourgadget.gadgetbridge.database.DBHelper.*; import javax.xml.xpath.XPath; import javax.xml.xpath.XPathFactory; 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; private int realtimeHRMInterval = 30*60; /// 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); /// Maximum amount of characters to store in receiveHistory public static final int MAX_RECEIVE_HISTORY_CHARS = 100000; // 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(); } 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: { LOG.info("ACTION_DEVICE_CHANGED " + (gbDevice!=null ? gbDevice.getStateString():"")); addReceiveHistory("\n================================================\nACTION_DEVICE_CHANGED "+gbDevice.getStateString()+" "+(new SimpleDateFormat("yyyy-mm-dd hh:mm:ss", Locale.US)).format(Calendar.getInstance().getTime())+"\n================================================\n"); } } } }; LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter); } private void registerGlobalIntents() { IntentFilter commandFilter = new IntentFilter(); commandFilter.addAction(BANGLE_ACTION_UART_TX); BroadcastReceiver commandReceiver = 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(commandReceiver, 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.setGattCallback(this); builder.notify(rxCharacteristic, true); Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); allowHighMTU = devicePrefs.getBoolean(PREF_ALLOW_HIGH_MTU, false); uartTx(builder, " \u0003"); // clear active line Prefs prefs = GBApplication.getPrefs(); if (prefs.getBoolean("datetime_synconconnect", true)) transmitTime(builder); //sendSettings(builder); // get version gbDevice.setState(GBDevice.State.INITIALIZED); gbDevice.sendDeviceUpdateIntent(getContext()); getDevice().setFirmwareVersion("N/A"); getDevice().setFirmwareVersion2("N/A"); lastBatteryPercent = -1; LOG.info("Initialization Done"); 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) json += "\\x"+Integer.toHexString((ch&255)|256).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 int/double/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); LOG.info("UART RX JSON parsed successfully"); handleUartRxJSON(json); } 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": { if (json.has("fw1")) getDevice().setFirmwareVersion(json.getString("fw1")); if (json.has("fw2")) getDevice().setFirmwareVersion2(json.getString("fw2")); } break; case "status": { 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); } 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 "notify" : { 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); } break; case "act": { BangleJSActivitySample sample = new BangleJSActivitySample(); sample.setTimestamp((int) (GregorianCalendar.getInstance().getTimeInMillis() / 1000L)); int hrm = 0; int steps = 0; if (json.has("hrm")) hrm = json.getInt("hrm"); if (json.has("stp")) steps = json.getInt("stp"); 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.setRawKind(activity); sample.setHeartRate(hrm); sample.setSteps(steps); try (DBHandler dbHandler = GBApplication.acquireDB()) { Long userId = getUser(dbHandler.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()).getId(); BangleJSSampleProvider provider = new BangleJSSampleProvider(getDevice(), dbHandler.getDaoSession()); sample.setDeviceId(deviceId); sample.setUserId(userId); provider.addGBActivitySample(sample); } catch (Exception ex) { LOG.warn("Error saving activity: " + ex.getLocalizedMessage()); } // push realtime data if (realtimeHRM || realtimeStep) { Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent); } } break; case "http": { Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); String _id=null; try { _id = json.getString("id"); } catch (JSONException e) { } final String id = _id; if (BuildConfig.INTERNET_ACCESS && devicePrefs.getBoolean(PREF_DEVICE_INTERNET_ACCESS, false)) { RequestQueue queue = Volley.newRequestQueue(getContext()); 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 = ""; try { _xmlPath = json.getString("xpath"); } catch (JSONException e) { } final String xmlPath = _xmlPath; // 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(); 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); 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; } }; queue.add(stringRequest); } else { if (BuildConfig.INTERNET_ACCESS) uartTxJSONError("http", "Internet access not enabled, check Gadgetbridge Device Settings",id); else uartTxJSONError("http", "Internet access not enabled in this Gadgetbridge build",id); } } break; case "intent": { Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); if (devicePrefs.getBoolean(PREF_DEVICE_INTENTS, false)) { 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); } } else { uartTxJSONError("intent", "Android Intents not enabled, check Gadgetbridge Device Settings", null); } break; } case "force_calendar_sync": { //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 Context context = GBApplication.getContext(); Intent intent = new Intent("FORCE_CALENDAR_SYNC"); intent.setPackage(BuildConfig.APPLICATION_ID); GBApplication.getContext().sendBroadcast(intent); } break; default : { LOG.info("UART RX JSON packet type '"+packetType+"' not understood."); } } } 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 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; String packetStr = new String(chars); LOG.info("RX: " + packetStr); // logging addReceiveHistory(packetStr); // split into input lines receivedLine += packetStr; while (receivedLine.contains("\n")) { int p = receivedLine.indexOf("\n"); String line = receivedLine.substring(0,p-1); receivedLine = receivedLine.substring(p+1); handleUartRxLine(line); } // Send an intent with new data Intent intent = new Intent(BangleJSDeviceSupport.BANGLEJS_COMMAND_RX); intent.putExtra("DATA", packetStr); intent.putExtra("SEQ", bangleCommandSeq++); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent); } return false; } void transmitTime(TransactionBuilder builder) { long ts = System.currentTimeMillis(); float tz = SimpleTimeZone.getDefault().getOffset(ts) / (1000 * 60 * 60.0f); // set time String cmd = "\u0010setTime("+(ts/1000)+");"; // set timezone cmd += "E.setTimeZone("+tz+");"; // write timezone to settings cmd += "(s=>{s&&(s.timezone="+tz+")&&require('Storage').write('setting.json',s);})(require('Storage').readJSON('setting.json',1))"; uartTx(builder, cmd+"\n"); } @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 result += renderUnicodeWordAsImage(word)+ch; } else { // or just copy across result += word+ch; } word = ""; needsTranslate = false; } else { // TODO: better check? if (ch<0 || ch>255) needsTranslate = true; word += ch; } } if (needsTranslate) { // convert word result += renderUnicodeWordAsImage(word); } else { // or just copy across result += word; } return result; } @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.getEnabled()) 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()); } 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 onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) { } @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 onInstallApp(Uri uri) { } @Override public void onAppInfoReq() { } @Override public void onAppStart(UUID uuid, boolean start) { } @Override public void onAppDelete(UUID uuid) { } @Override public void onAppConfiguration(UUID appUuid, String config, Integer id) { } @Override public void onAppReorder(UUID[] uuids) { } @Override public void onFetchRecordedData(int dataTypes) { if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS) { 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); } } } @Override public void onReset(int flags) { } @Override public void onHeartRateTest() { } @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 onScreenshotReq() { } @Override public void onEnableHeartRateSleepSupport(boolean enable) { } @Override public void onSetHeartRateMeasurementInterval(int seconds) { realtimeHRMInterval = seconds; transmitActivityStatus(); } @Override public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) { try { JSONObject o = new JSONObject(); o.put("t", "calendar"); //TODO implement command 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(calendarEventSpec.title)); o.put("description", renderUnicodeAsImage(calendarEventSpec.description)); o.put("location", renderUnicodeAsImage(calendarEventSpec.location)); o.put("calName", calendarEventSpec.calName); 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 onSendConfiguration(String config) { } @Override public void onReadConfiguration(String config) { } @Override public void onTestNewFunction() { } @Override public void onSendWeather(WeatherSpec weatherSpec) { try { JSONObject o = new JSONObject(); o.put("t", "weather"); o.put("temp", weatherSpec.currentTemp); o.put("hum", weatherSpec.currentHumidity); 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); 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()); } } }