mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-06-10 07:07:57 +02:00
665656ddc0
NotificationListener now stores the handle ID in wearableAction.handle rather than hard-coding the calculation Should fix ZeppOS too which was copy&paste from Bangle.js
1897 lines
85 KiB
Java
1897 lines
85 KiB
Java
/* 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.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) {
|
||
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(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", 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());
|
||
}
|
||
}
|
||
}
|