/*  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 <https://www.gnu.org/licenses/>. */
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.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.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.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.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<Integer, 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); // 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;i<bytes.length;i+=mtuSize) {
            int l = bytes.length-i;
            if (l>mtuSize) 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;i<s.length();i++) {
                int ch = (int)s.charAt(i); // unicode, so 0..65535 (usually)
                int nextCh = (int)(i+1<s.length() ? s.charAt(i+1) : 0); // 0..65535
                //rawString = rawString+ch+",";
                if (ch>255) 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;i<a.length();i++) {
                if (i>0) 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<String> 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 {
        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 = 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<String,String> _headers = null;
        if (json.has("headers")) {
            JSONObject h = json.getJSONObject("headers");
            _headers = new HashMap<String,String>();
            Iterator<String> 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<String,String> 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<String>() {
                    @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<String, String> getHeaders() throws AuthFailureError {
                // clone the data from super.getHeaders() so we can write to it
                Map<String, String> 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<Long> idsList = new ArrayList<>(ids.length());
        try (DBHandler dbHandler = GBApplication.acquireDB()) {
            DaoSession session = dbHandler.getDaoSession();
            Long deviceId = DBHelper.getDevice(gbDevice, session).getId();
            QueryBuilder<CalendarSyncState> qb = session.getCalendarSyncStateDao().queryBuilder();
            //FIXME just use that and don't query every time?
            List<CalendarSyncState> 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<String> 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<Intent> 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;i<chars.length;i++) {
                boolean ignoreChar = false;
                if (chars[i]==19 /* XOFF */) {
                    getQueue().setPaused(true);
                    LOG.info("RX: XOFF");
                    ignoreChar = true;
                }
                if (chars[i]==17 /* XON */) {
                    getQueue().setPaused(false);
                    LOG.info("RX: XON");
                    ignoreChar = true;
                }
                if (ignoreChar) {
                    // remove char from the array. Generally only one XON/XOFF per stream so creating a new array each time is fine
                    byte[] c = new byte[chars.length - 1];
                    System.arraycopy(chars, 0, c, 0, i); // copy before
                    System.arraycopy(chars, i+1, c, i, chars.length - i - 1); // copy after
                    chars = c;
                    i--; // back up one (because we deleted it)
                }
            }
            String packetStr = new String(chars, StandardCharsets.ISO_8859_1);
            LOG.debug("RX: " + packetStr);
            // logging
            addReceiveHistory(packetStr);
            // split into input lines
            receivedLine += packetStr;
            while (receivedLine.contains("\n")) {
                int p = receivedLine.indexOf("\n");
                String line = receivedLine.substring(0,(p>0) ? (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 {
                    GBLocationManager.start(getContext(), this, LocationProviderType.GPS, intervalLength);
                } catch (IllegalArgumentException e) {
                    LOG.warn("GPS provider could not be started", e);
                }
            }

            try {
                GBLocationManager.start(getContext(), this, LocationProviderType.NETWORK, intervalLength);
            } catch (IllegalArgumentException e) {
                LOG.warn("NETWORK provider could not be started", e);
            }
        } else {
            GB.toast("Phone gps data update is deactivated in the settings", Toast.LENGTH_SHORT, GB.INFO);
        }
        gpsUpdateSetup = true;
    }

    @Override
    public void onSetGpsLocation(final Location location) {
        if (!GBApplication.getPrefs().getBoolean("use_updated_location_if_available", false)) return;
        LOG.debug("new location: " + location.toString());
        JSONObject o = new JSONObject();
        try {
            o.put("t", "gps");
            o.put("lat", location.getLatitude());
            o.put("lon", location.getLongitude());
            o.put("alt", location.getAltitude());
            o.put("speed", location.getSpeed()*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<word.length();i++) {
            char ch = word.charAt(i);
            hasCJK |= ch>=0x4E00 && ch<=0x9FFF; // "CJK Unified Ideographs" block
        }
        if (hasCJK) {
            // split every 2 chars
            StringBuilder result = new StringBuilder();
            for (int i=0;i<word.length();i+=2) {
                int len = 2;
                if (i+len > 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<txt.length();i++) {
            char ch = txt.charAt(i);
            // Special cases where we can just use a built-in character...
            // Based on https://op.europa.eu/en/web/eu-vocabularies/formex/physical-specifications/character-encoding
            if (ch=='–' || ch=='‐' || ch=='—') ch='-';
            else if (ch =='‚' || ch=='，' || ch=='、') ch=',';
            else if (ch =='。') ch='.';
            else if (ch =='【') ch='[';
            else if (ch =='】') ch=']';
            else if (ch=='‘' || ch=='’' || ch=='‛' || ch=='′' || ch=='ʹ') ch='\'';
            else if (ch=='“' || ch=='”' || ch =='„' || ch=='‟' || ch=='″') ch='"';
            // chars which break words up
            if (" -_/:.,?!'\"&*()[]".indexOf(ch)>=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<notificationSpec.attachedActions.size();i++) {
                NotificationSpec.Action action = notificationSpec.attachedActions.get(i);
                if (action.type==NotificationSpec.Action.TYPE_WEARABLE_REPLY) {
                    mNotificationReplyAction.add(notificationSpec.getId(), action.handle);
                    canReply = true;
                }
            }
        // sourceName isn't set for SMS messages
        String src = notificationSpec.sourceName;
        if (notificationSpec.type == NotificationType.GENERIC_SMS)
            src = "SMS Message";
        // Send JSON to Bangle.js
        try {
            JSONObject o = new JSONObject();
            o.put("t", "notify");
            o.put("id", notificationSpec.getId());
            o.put("src", src);
            o.put("title", renderUnicodeAsImage(cropToLength(notificationSpec.title,80)));
            o.put("subject", renderUnicodeAsImage(cropToLength(notificationSpec.subject,80)));
            o.put("body", renderUnicodeAsImage(cropToLength(notificationSpec.body, 400)));
            o.put("sender", renderUnicodeAsImage(cropToLength(notificationSpec.sender,40)));
            o.put("tel", notificationSpec.phoneNumber);
            if (canReply) o.put("reply", true);
            uartTxJSON("onNotification", o);
        } catch (JSONException e) {
            LOG.info("JSONException: " + e.getLocalizedMessage());
        }
    }

    @Override
    public void onDeleteNotification(int id) {
        try {
            JSONObject o = new JSONObject();
            o.put("t", "notify-");
            o.put("id", id);
            uartTxJSON("onDeleteNotification", o);
        } catch (JSONException e) {
            LOG.info("JSONException: " + e.getLocalizedMessage());
        }
    }

    @Override
    public void onSetTime() {
        try {
            TransactionBuilder builder = performInitialized("setTime");
            transmitTime(builder);
            //TODO: once we have a common strategy for sending events (e.g. EventHandler), remove this call from here. Meanwhile it does no harm.
            // = we should generalize the pebble calender code
            forceCalendarSync();
            builder.queue(getQueue());
        } catch (Exception e) {
            GB.toast(getContext(), "Error setting time: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
        }
    }

    @Override
    public void onSetAlarms(ArrayList<? extends Alarm> 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<LoyaltyCard> filterSupportedCards(final List<LoyaltyCard> cards) {
        final List<LoyaltyCard> 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<LoyaltyCard> cards) {
        final List<LoyaltyCard> 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("<html"))
                description = androidx.core.text.HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
            // Replace "-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-" lines from Google meet
            description = ("\n"+description+"\n").replaceAll("\n-[:~-]*\n","");
            // replace double newlines and trim beginning and end
            description = description.replaceAll("\n\\s*\n","\n").trim();
        }
        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(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(ArrayList<WeatherSpec> 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]<minColUsage) {
                    minColUsage = colUsage[c];
                    transparentColorIndex = (byte)c;
                }
            }
            // rewrite any transparent pixels as the correct color for transparency
            for (n=0;n<pixels.length;n++)
                if (pixels[n]==PIXELCOL_TRANSPARENT)
                    pixels[n] = transparentColorIndex;
        }
        // if we're MONOCHROME_TRANSPARENT, force transparency on bg color
        if (style == BangleJSBitmapStyle.MONOCHROME_TRANSPARENT) {
            isTransparent = true;
            transparentColorIndex = 0;
        }
        // Write the header
        int headerLen = isTransparent ? 4 : 3;
        byte[] bmp = new byte[(((height * width * bpp) + 7) >> 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<navActions.length)
                o.put("action", navActions[navigationInfoSpec.nextAction]);
            if (navigationInfoSpec.ETA!=null)
                o.put("eta", navigationInfoSpec.ETA);
            uartTxJSON("onSetNavigationInfo", o);
        } catch (JSONException e) {
            LOG.info("JSONException: " + e.getLocalizedMessage());
        }
    }
}
