/* Copyright (C) 2019-2024 Albert, Andreas Shimokawa, Arjan Schrijver, Damien Gaignon, Gabriele Monaco, Ganblejs, gfwilliams, glemco, Gordon Williams, halemmerich, illis, José Rebelo, Lukas, LukasEdl, Marc Nause, Martin Boonk, rarder44, Richard de Boer, Simon Sievert 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.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_NOTIFICATION_WAKE_ON_OPEN; 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.core.text.HtmlCompat; 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.activities.WakeActivity; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.BarcodeFormat; import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard; 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.deviceevents.GBDeviceEventScreenshot; 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.GBLocationService; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType; 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.NotificationType; 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.service.btle.actions.SetDeviceStateAction; 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 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"); GBLocationService.stop(getContext(), getDevice()); 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); // should be RECEIVER_EXPORTED } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { LOG.info("Initializing"); gbDevice.setState(GBDevice.State.INITIALIZING); gbDevice.sendDeviceUpdateIntent(getContext()); gbDevice.setBatteryThresholdPercent((short) 15); rxCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_RX); txCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_TX); if (rxCharacteristic==null || txCharacteristic==null) { // https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/2996 - sometimes we get // initializeDevice called but no characteristics have been fetched - try and reconnect in that case LOG.warn("RX/TX characteristics are null, will attempt to reconnect"); builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext())); } builder.setCallback(this); builder.notify(rxCharacteristic, true); Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); allowHighMTU = devicePrefs.getBoolean(PREF_ALLOW_HIGH_MTU, true); if (allowHighMTU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { builder.requestMtu(131); } // 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; StringBuilder json = new StringBuilder("\""); boolean hasUnicode = false; //String rawString = ""; for (int i=0;i255) hasUnicode = true; if (ch<8) { // if the next character is a digit, it'd be interpreted // as a 2 digit octal character, so we can't use `\0` to escape it if (nextCh>='0' && nextCh<='7') json.append("\\x0").append(ch); else json.append("\\").append(ch); } else if (ch==8) json.append("\\b"); else if (ch==9) json.append("\\t"); else if (ch==10) json.append("\\n"); else if (ch==11) json.append("\\v"); else if (ch==12) json.append("\\f"); else if (ch==34) json.append("\\\""); // quote else if (ch==92) json.append("\\\\"); // slash else if (ch<32 || ch==127 || ch==173 || ((ch>=0xC2) && (ch<=0xF4))) // unicode start char range json.append("\\x").append(Integer.toHexString((ch & 255) | 256).substring(1)); else if (ch>255) json.append("\\u").append(Integer.toHexString((ch & 65535) | 65536).substring(1)); else json.append(s.charAt(i)); } // if it was less characters to send base64, do that! if (!hasUnicode && (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.append("\"").toString(); } else if (v instanceof JSONArray) { JSONArray a = (JSONArray)v; StringBuilder json = new StringBuilder("["); for (int i=0;i0) json.append(","); Object o = null; try { o = a.get(i); } catch (JSONException e) { LOG.warn("jsonToString array error: " + e.getLocalizedMessage()); } json.append(jsonToStringInternal(o)); } return json.append("]").toString(); } else if (v instanceof JSONObject) { JSONObject obj = (JSONObject)v; StringBuilder json = new StringBuilder("{"); 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.append("\"").append(key).append("\":").append(jsonToStringInternal(o)); if (iter.hasNext()) json.append(","); } return json.append("}").toString(); } 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(), "'Android Integration' 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.error("UART RX JSON parse failure: "+ e.getLocalizedMessage()); GB.toast(getContext(), "Malformed JSON from Bangle.js: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); } } else if (line.startsWith("data:image/bmp;base64,")) { LOG.debug("Got screenshot bmp"); final byte[] screenshotBytes = Base64.decode(line.substring(21), Base64.DEFAULT); final GBDeviceEventScreenshot gbDeviceEventScreenshot = new GBDeviceEventScreenshot(screenshotBytes); evaluateGBDeviceEvent(gbDeviceEventScreenshot); } 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 "actTrksList": { JSONObject requestTrackObj = BangleJSActivityTrack.handleActTrksList(json, getDevice(), getContext()); if (requestTrackObj!=null) uartTxJSON("requestActivityTrackLog", requestTrackObj); } break; case "actTrk": { JSONObject requestTrackObj = BangleJSActivityTrack.handleActTrk(json, getDevice(), getContext()); if (requestTrackObj!=null) uartTxJSON("requestActivityTrackLog", requestTrackObj); } break; case "http": handleHttp(json); break; case "force_calendar_sync": handleCalendarSync(json); break; case "intent": handleIntent(json); break; case "file": handleFile(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 { String response = json.getString("n").toUpperCase(); LOG.debug("Notification response: " + response); // Wake the Android device if the setting is toggled on by user. // Doesn't work if run after the notification handling below for some reason (which // I'd rather do since the screen would only be opened once the content was ready). Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); if (devicePrefs.getBoolean(PREF_NOTIFICATION_WAKE_ON_OPEN, false) && response.equals("OPEN")) { WakeActivity.start(getContext()); } GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl(); // .title appears unused deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.valueOf(response); 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 = 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) { 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; } private void handleFile(JSONObject json) throws JSONException { File dir; try { dir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName())); if (!dir.isDirectory()) { if (!dir.mkdir()) { throw new IOException("Cannot create device specific directory for " + getDevice().getName()); } } } catch (IOException e) { LOG.error("Could not get directory to write to with error: " + e); return; } String filename = json.getString("n"); String filenameThatCantEscapeDir = filename.replaceAll("/",""); LOG.debug("Compare filename and filenameThatCantEscapeDir:\n" + filename + "\n" + filenameThatCantEscapeDir); File outputFile = new File(dir, filenameThatCantEscapeDir); String mode = "append"; if (json.getString("m").equals("w")) { mode = "write"; } try { FileUtils.copyStringToFile(json.getString("c"), outputFile, mode); LOG.info("Writing to "+outputFile); } catch (IOException e) { LOG.warn("Could not write to " + outputFile + "with error: " + e); } } @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;i0) ? (p-1) : 0); 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"); } 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 { GBLocationService.start(getContext(), getDevice(), GBLocationProviderType.GPS, intervalLength); } catch (IllegalArgumentException e) { LOG.warn("GPS provider could not be started", e); } } try { GBLocationService.start(getContext(), getDevice(), GBLocationProviderType.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()*3.6); // m/s to kph if (location.hasBearing()) o.put("course", location.getBearing()); o.put("time", location.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 renderUnicodeWordPartAsImage(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) BangleJSBitmapStyle style = hasEmoji ? BangleJSBitmapStyle.RGB_3BPP_TRANSPARENT : BangleJSBitmapStyle.MONOCHROME_TRANSPARENT; return "\0"+bitmapToEspruinoString(textToBitmap(word), style); } private String renderUnicodeWordAsImage(String word) { // if we have Chinese/Japanese/Korean chars, split into 2 char chunks to allow easier text wrapping // it's not perfect but better than nothing boolean hasCJK = false; for (int i=0;i=0x4E00 && ch<=0x9FFF; // "CJK Unified Ideographs" block } if (hasCJK) { // split every 2 chars StringBuilder result = new StringBuilder(); for (int i=0;i word.length()) len = word.length()-i; result.append(renderUnicodeWordPartAsImage(word.substring(i, i + len))); } return result.toString(); } // else just render the word as-is return renderUnicodeWordPartAsImage(word); } 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 = ""; StringBuilder result = new StringBuilder(); boolean needsTranslate = false; for (int i=0;i=0) { // word split if (needsTranslate) { // convert word LOG.info("renderUnicodeAsImage converting " + word); result.append(renderUnicodeWordAsImage(word)).append(ch); } else { // or just copy across result.append(word).append(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.append(renderUnicodeWordAsImage(word)); } else { // or just copy across result.append(word); } return result.toString(); } /// 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 (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) { LOG.debug("App notifications disabled - ignoring"); return; } boolean canReply = false; 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_GPS_TRACKS) !=0) { JSONObject requestTracksListObj = BangleJSActivityTrack.compileTracksListRequest(getDevice(), getContext()); uartTxJSON("requestActivityTracksList", requestTracksListObj); } 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 onScreenshotReq() { try { final TransactionBuilder builder = performInitialized("screenshot"); uartTx(builder, "\u0010g.dump()\n"); builder.queue(getQueue()); } catch (final IOException e) { GB.toast(getContext(), "Failed to get screenshot: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); } } @Override public void onSetHeartRateMeasurementInterval(int seconds) { realtimeHRMInterval = seconds; transmitActivityStatus(); } private List filterSupportedCards(final List cards) { final List ret = new ArrayList<>(); for (final LoyaltyCard card : cards) { // we hardcode here what is supported if (card.getBarcodeFormat() == BarcodeFormat.CODE_39 || card.getBarcodeFormat() == BarcodeFormat.CODABAR || card.getBarcodeFormat() == BarcodeFormat.QR_CODE) { ret.add(card); } } return ret; } @Override public void onSetLoyaltyCards(final ArrayList cards) { final List supportedCards = filterSupportedCards(cards); try { JSONObject encoded_cards = new JSONObject(); JSONArray a = new JSONArray(); for (final LoyaltyCard card : supportedCards) { JSONObject o = new JSONObject(); o.put("id", card.getId()); o.put("name", renderUnicodeAsImage(cropToLength(card.getName(),40))); if (card.getBarcodeId() != null) { o.put("value", card.getBarcodeId()); } else { o.put("value", card.getCardId()); } if (card.getBarcodeFormat() != null) o.put("type", card.getBarcodeFormat().toString()); if (card.getExpiry() != null) o.put("expiration", card.getExpiry().getTime()/1000); o.put("color", card.getColor()); // we somehow cannot distinguish no balance defined with 0 P if (card.getBalance() != null && card.getBalance().signum() != 0 || card.getBalanceType() != null) { // if currency is points it is not reported String balanceType = card.getBalanceType() != null ? card.getBalanceType().toString() : "P"; o.put("balance", renderUnicodeAsImage(cropToLength(card.getBalance() + " " + balanceType, 20))); } if (card.getNote() != null) o.put("note", renderUnicodeAsImage(cropToLength(card.getNote(),200))); a.put(o); } encoded_cards.put("t", "cards"); encoded_cards.put("d", a); uartTxJSON("onSetLoyaltyCards", encoded_cards); } catch (JSONException e) { LOG.info("JSONException: " + e.getLocalizedMessage()); } } @Override public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) { String description = calendarEventSpec.description; if (description != null) { // remove any HTML formatting if (description.startsWith(" weatherSpecs) { WeatherSpec weatherSpec = weatherSpecs.get(0); 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", Math.round(weatherSpec.windSpeed*100)/100.0); 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(); if (width>255) { LOG.warn("bitmapToEspruinoArray width of "+width+" > 255 (Espruino max) - cropping"); width = 255; } if (height>255) { LOG.warn("bitmapToEspruinoArray height of "+height+" > 255 (Espruino max) - cropping"); height = 255; } 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