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