mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-06-02 11:26:09 +02:00
81aef0bf35
Introduce the concept of primary and secondary weathers: * Primary weather keeps the same behavior as previously across all weather providers, so it's non-breaking. This location is not necessarily the current location, just the primary weather location set by the user. * The GenericWeatherReceiver now has a new extra WeatherSecondaryJson, that receives a json list with secondary weather locations. It's guaranteed that the primary weather always exists, so the list of WeatherSpecs provided to devices is never empty. Update all support classes accordingly.
1898 lines
85 KiB
Java
1898 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(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());
|
||
}
|
||
}
|
||
}
|