A free and cloudless replacement for your gadget vendors' closed source Android applications. See the list for supported devices.
https://gadgetbridge.org
android
woodpecker-ci
woodpeckerci
android-application
amazfit
cor
bip
miband
teclast
pebble
sexy
zetime
xwatch
no1-f1
lenovo-watch
casio
mi-band
hplus
tws
fitpro
banglejs
fossil
pebble-smartwatch
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1188 lines
50 KiB
1188 lines
50 KiB
/* Copyright (C) 2019-2021 Andreas Shimokawa, Gordon Williams |
|
|
|
This file is part of Gadgetbridge. |
|
|
|
Gadgetbridge is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Affero General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
Gadgetbridge is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Affero General Public License for more details. |
|
|
|
You should have received a copy of the GNU Affero General Public License |
|
along with this program. If not, see <http://www.gnu.org/licenses/>. */ |
|
package nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs; |
|
|
|
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.graphics.Bitmap; |
|
import android.graphics.BitmapFactory; |
|
import android.graphics.Canvas; |
|
import android.graphics.Paint; |
|
import android.graphics.drawable.BitmapDrawable; |
|
import android.graphics.drawable.Drawable; |
|
import android.net.Uri; |
|
import android.util.Base64; |
|
import android.widget.Toast; |
|
|
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager; |
|
|
|
import com.android.volley.Request; |
|
import com.android.volley.Response; |
|
import com.android.volley.RequestQueue; |
|
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.xml.sax.InputSource; |
|
|
|
import java.io.BufferedWriter; |
|
import java.io.File; |
|
import java.io.FileOutputStream; |
|
import java.io.FileWriter; |
|
import java.io.IOException; |
|
import java.io.StringReader; |
|
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.SimpleTimeZone; |
|
import java.util.UUID; |
|
import java.lang.reflect.Field; |
|
|
|
import io.wax911.emojify.Emoji; |
|
import io.wax911.emojify.EmojiManager; |
|
import io.wax911.emojify.EmojiUtils; |
|
import nodomain.freeyourgadget.gadgetbridge.BuildConfig; |
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication; |
|
import nodomain.freeyourgadget.gadgetbridge.R; |
|
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; |
|
import nodomain.freeyourgadget.gadgetbridge.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.devices.banglejs.BangleJSConstants; |
|
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSSampleProvider; |
|
import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySample; |
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; |
|
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; |
|
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.CannedMessagesSpec; |
|
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; |
|
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; |
|
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; |
|
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; |
|
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; |
|
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; |
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; |
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; |
|
import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; |
|
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; |
|
|
|
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU; |
|
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTERNET_ACCESS; |
|
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTENTS; |
|
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP; |
|
import static nodomain.freeyourgadget.gadgetbridge.database.DBHelper.*; |
|
|
|
import javax.xml.xpath.XPath; |
|
import javax.xml.xpath.XPathFactory; |
|
|
|
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; |
|
private int realtimeHRMInterval = 30*60; |
|
|
|
private final LimitedQueue/*Long*/ mNotificationReplyAction = new LimitedQueue(16); |
|
|
|
/// Maximum amount of characters to store in receiveHistory |
|
public static final int MAX_RECEIVE_HISTORY_CHARS = 100000; |
|
|
|
// 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(); |
|
} |
|
|
|
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")); |
|
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; |
|
} |
|
case GBDevice.ACTION_DEVICE_CHANGED: { |
|
LOG.info("ACTION_DEVICE_CHANGED " + gbDevice.getStateString()); |
|
addReceiveHistory("\n================================================\nACTION_DEVICE_CHANGED "+gbDevice.getStateString()+" "+(new SimpleDateFormat("yyyy-mm-dd hh:mm:ss")).format(Calendar.getInstance().getTime())+"\n================================================\n"); |
|
} |
|
} |
|
} |
|
}; |
|
LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter); |
|
} |
|
|
|
private void registerGlobalIntents() { |
|
IntentFilter commandFilter = new IntentFilter(); |
|
commandFilter.addAction(BANGLE_ACTION_UART_TX); |
|
BroadcastReceiver commandReceiver = 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(commandReceiver, commandFilter); |
|
} |
|
|
|
@Override |
|
protected TransactionBuilder initializeDevice(TransactionBuilder builder) { |
|
LOG.info("Initializing"); |
|
|
|
gbDevice.setState(GBDevice.State.INITIALIZING); |
|
gbDevice.sendDeviceUpdateIntent(getContext()); |
|
gbDevice.setBatteryThresholdPercent((short) 30); |
|
|
|
rxCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_RX); |
|
txCharacteristic = getCharacteristic(BangleJSConstants.UUID_CHARACTERISTIC_NORDIC_UART_TX); |
|
builder.setGattCallback(this); |
|
builder.notify(rxCharacteristic, true); |
|
|
|
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); |
|
allowHighMTU = devicePrefs.getBoolean(PREF_ALLOW_HIGH_MTU, false); |
|
|
|
uartTx(builder, " \u0003"); // clear active line |
|
|
|
Prefs prefs = GBApplication.getPrefs(); |
|
if (prefs.getBoolean("datetime_synconconnect", true)) |
|
transmitTime(builder); |
|
//sendSettings(builder); |
|
|
|
// get version |
|
|
|
gbDevice.setState(GBDevice.State.INITIALIZED); |
|
gbDevice.sendDeviceUpdateIntent(getContext()); |
|
|
|
getDevice().setFirmwareVersion("N/A"); |
|
getDevice().setFirmwareVersion2("N/A"); |
|
|
|
LOG.info("Initialization Done"); |
|
|
|
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; |
|
String json = "\""; |
|
//String rawString = ""; |
|
for (int i=0;i<s.length();i++) { |
|
int ch = (int)s.charAt(i); // 0..255 |
|
int nextCh = (int)(i+1<s.length() ? s.charAt(i+1) : 0); // 0..255 |
|
//rawString = rawString+ch+","; |
|
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 += "\\x0" + ch; |
|
else json += "\\" + ch; |
|
} else if (ch==8) json += "\\b"; |
|
else if (ch==9) json += "\\t"; |
|
else if (ch==10) json += "\\n"; |
|
else if (ch==11) json += "\\v"; |
|
else if (ch==12) json += "\\f"; |
|
else if (ch==34) json += "\\\""; // quote |
|
else if (ch==92) json += "\\\\"; // slash |
|
else if (ch<32 || ch==127 || ch==173) |
|
json += "\\x"+Integer.toHexString((ch&255)|256).substring(1); |
|
else json += s.charAt(i); |
|
} |
|
// if it was less characters to send base64, do that! |
|
if (json.length() > 5+(s.length()*4/3)) { |
|
byte[] bytes = s.getBytes(StandardCharsets.ISO_8859_1); |
|
return "atob(\""+Base64.encodeToString(bytes, Base64.DEFAULT).replaceAll("\n","")+"\")"; |
|
} |
|
// for debugging... |
|
//addReceiveHistory("\n---------------------\n"+rawString+"\n---------------------\n"); |
|
return json + "\""; |
|
} else if (v instanceof JSONArray) { |
|
JSONArray a = (JSONArray)v; |
|
String json = "["; |
|
for (int i=0;i<a.length();i++) { |
|
if (i>0) json += ","; |
|
Object o = null; |
|
try { |
|
o = a.get(i); |
|
} catch (JSONException e) { |
|
LOG.warn("jsonToString array error: " + e.getLocalizedMessage()); |
|
} |
|
json += jsonToStringInternal(o); |
|
} |
|
return json+"]"; |
|
} else if (v instanceof JSONObject) { |
|
JSONObject obj = (JSONObject)v; |
|
String json = "{"; |
|
Iterator<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 += key+":"+jsonToStringInternal(o); |
|
if (iter.hasNext()) json+=","; |
|
} |
|
return json+"}"; |
|
} // else int/double/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); |
|
} |
|
} |
|
|
|
/// Write JSON object of the form {t:taskName, err:message} |
|
|
|
private void uartTxJSONError(String taskName, String message) { |
|
uartTxJSONError(taskName,message,null); |
|
} |
|
|
|
private void uartTxJSONError(String taskName, String message,String id) { |
|
JSONObject o = new JSONObject(); |
|
try { |
|
o.put("t", taskName); |
|
if( id!=null) |
|
o.put("id", id); |
|
o.put("err", message); |
|
} catch (JSONException e) { |
|
GB.toast(getContext(), "uartTxJSONError: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); |
|
} |
|
uartTxJSON(taskName, o); |
|
} |
|
|
|
|
|
|
|
private void handleUartRxLine(String line) { |
|
LOG.info("UART RX LINE: " + line); |
|
if (line.length()==0) return; |
|
if (">Uncaught ReferenceError: \"GB\" is not defined".equals(line)) |
|
GB.toast(getContext(), "Gadgetbridge plugin not installed on Bangle.js", Toast.LENGTH_LONG, GB.ERROR); |
|
else if (line.charAt(0)=='{') { |
|
// JSON - we hope! |
|
try { |
|
JSONObject json = new JSONObject(line); |
|
LOG.info("UART RX JSON parsed successfully"); |
|
handleUartRxJSON(json); |
|
} catch (JSONException e) { |
|
LOG.info("UART RX JSON parse failure: "+ e.getLocalizedMessage()); |
|
GB.toast(getContext(), "Malformed JSON from Bangle.js: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); |
|
} |
|
} else { |
|
LOG.info("UART RX line started with "+(int)line.charAt(0)+" - ignoring"); |
|
} |
|
} |
|
|
|
private void handleUartRxJSON(JSONObject json) throws JSONException { |
|
String packetType = json.getString("t"); |
|
switch (packetType) { |
|
case "info": |
|
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.INFO); |
|
break; |
|
case "warn": |
|
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.WARN); |
|
break; |
|
case "error": |
|
GB.toast(getContext(), "Bangle.js: " + json.getString("msg"), Toast.LENGTH_LONG, GB.ERROR); |
|
break; |
|
case "ver": { |
|
if (json.has("fw1")) |
|
getDevice().setFirmwareVersion(json.getString("fw1")); |
|
if (json.has("fw2")) |
|
getDevice().setFirmwareVersion2(json.getString("fw2")); |
|
} break; |
|
case "status": { |
|
GBDeviceEventBatteryInfo batteryInfo = new GBDeviceEventBatteryInfo(); |
|
if (json.has("bat")) { |
|
int b = json.getInt("bat"); |
|
if (b < 0) b = 0; |
|
if (b > 100) b = 100; |
|
batteryInfo.level = b; |
|
batteryInfo.state = BatteryState.BATTERY_NORMAL; |
|
} |
|
if (json.has("chg") && json.getInt("chg") == 1) { |
|
batteryInfo.state = BatteryState.BATTERY_CHARGING; |
|
} |
|
if (json.has("volt")) |
|
batteryInfo.voltage = (float) json.getDouble("volt"); |
|
handleGBDeviceEvent(batteryInfo); |
|
} 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 "notify" : { |
|
GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl(); |
|
// .title appears unused |
|
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.valueOf(json.getString("n").toUpperCase()); |
|
if (json.has("id")) |
|
deviceEvtNotificationControl.handle = json.getInt("id"); |
|
if (json.has("tel")) |
|
deviceEvtNotificationControl.phoneNumber = json.getString("tel"); |
|
if (json.has("msg")) |
|
deviceEvtNotificationControl.reply = json.getString("msg"); |
|
/* REPLY responses don't use the ID from the event (MUTE/etc seem to), but instead |
|
* they use a handle that was provided in an action list on the onNotification.. event */ |
|
if (deviceEvtNotificationControl.event == GBDeviceEventNotificationControl.Event.REPLY) { |
|
Long foundHandle = (Long)mNotificationReplyAction.lookup((int)deviceEvtNotificationControl.handle); |
|
if (foundHandle!=null) |
|
deviceEvtNotificationControl.handle = foundHandle; |
|
} |
|
evaluateGBDeviceEvent(deviceEvtNotificationControl); |
|
} break; |
|
case "act": { |
|
BangleJSActivitySample sample = new BangleJSActivitySample(); |
|
sample.setTimestamp((int) (GregorianCalendar.getInstance().getTimeInMillis() / 1000L)); |
|
int hrm = 0; |
|
int steps = 0; |
|
if (json.has("hrm")) hrm = json.getInt("hrm"); |
|
if (json.has("stp")) steps = json.getInt("stp"); |
|
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.setRawKind(activity); |
|
sample.setHeartRate(hrm); |
|
sample.setSteps(steps); |
|
try (DBHandler dbHandler = GBApplication.acquireDB()) { |
|
Long userId = getUser(dbHandler.getDaoSession()).getId(); |
|
Long deviceId = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()).getId(); |
|
BangleJSSampleProvider provider = new BangleJSSampleProvider(getDevice(), dbHandler.getDaoSession()); |
|
sample.setDeviceId(deviceId); |
|
sample.setUserId(userId); |
|
provider.addGBActivitySample(sample); |
|
} catch (Exception ex) { |
|
LOG.warn("Error saving activity: " + ex.getLocalizedMessage()); |
|
} |
|
// push realtime data |
|
if (realtimeHRM || realtimeStep) { |
|
Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) |
|
.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample); |
|
LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent); |
|
} |
|
} break; |
|
case "http": { |
|
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); |
|
String _id=null; |
|
try { |
|
_id = json.getString("id"); |
|
} catch (JSONException e) { |
|
} |
|
final String id = _id; |
|
|
|
|
|
if (BuildConfig.INTERNET_ACCESS && devicePrefs.getBoolean(PREF_DEVICE_INTERNET_ACCESS, false)) { |
|
RequestQueue queue = Volley.newRequestQueue(getContext()); |
|
String url = json.getString("url"); |
|
|
|
String _xmlPath = ""; |
|
try { |
|
_xmlPath = json.getString("xpath"); |
|
} catch (JSONException e) { |
|
} |
|
final String xmlPath = _xmlPath; |
|
// Request a string response from the provided URL. |
|
StringRequest stringRequest = new StringRequest(Request.Method.GET, 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(); |
|
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); |
|
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) { |
|
JSONObject o = new JSONObject(); |
|
uartTxJSONError("http", error.toString(),id); |
|
} |
|
}); |
|
queue.add(stringRequest); |
|
} else { |
|
if (BuildConfig.INTERNET_ACCESS) |
|
uartTxJSONError("http", "Internet access not enabled, check Gadgetbridge Device Settings",id); |
|
else |
|
uartTxJSONError("http", "Internet access not enabled in this Gadgetbridge build",id); |
|
} |
|
} break; |
|
case "intent": { |
|
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); |
|
if (devicePrefs.getBoolean(PREF_DEVICE_INTENTS, false)) { |
|
String action = json.getString("action"); |
|
JSONObject extra = json.getJSONObject("extra"); |
|
Intent in = new Intent(); |
|
in.setAction(action); |
|
if (extra != null) { |
|
Iterator<String> iter = extra.keys(); |
|
while (iter.hasNext()) { |
|
String key = iter.next(); |
|
in.putExtra(key, extra.getString(key)); |
|
} |
|
} |
|
LOG.info("Sending intent " + action); |
|
this.getContext().getApplicationContext().sendBroadcast(in); |
|
} else { |
|
uartTxJSONError("intent", "Android Intents not enabled, check Gadgetbridge Device Settings"); |
|
} |
|
} |
|
default : { |
|
LOG.info("UART RX JSON packet type '"+packetType+"' not understood."); |
|
} |
|
} |
|
} |
|
|
|
@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; |
|
String packetStr = new String(chars); |
|
LOG.info("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-1); |
|
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"); |
|
} |
|
|
|
@Override |
|
public boolean useAutoConnect() { |
|
return true; |
|
} |
|
|
|
private String renderUnicodeWordAsImage(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; |
|
// if we had emoji, ensure we create 3 bit color (not 1 bit B&W) |
|
return "\0"+bitmapToEspruinoString(textToBitmap(word), hasEmoji ? BangleJSBitmapStyle.RGB_3BPP : BangleJSBitmapStyle.MONOCHROME); |
|
} |
|
|
|
public String renderUnicodeAsImage(String txt) { |
|
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 = "", result = ""; |
|
boolean needsTranslate = false; |
|
for (int i=0;i<txt.length();i++) { |
|
char ch = txt.charAt(i); |
|
if (" -_/:.,?!'\"&*()".indexOf(ch)>=0) { |
|
// word split |
|
if (needsTranslate) { // convert word |
|
result += renderUnicodeWordAsImage(word)+ch; |
|
} else { // or just copy across |
|
result += word+ch; |
|
} |
|
word = ""; |
|
needsTranslate = false; |
|
} else { |
|
// TODO: better check? |
|
if (ch<0 || ch>255) needsTranslate = true; |
|
word += ch; |
|
} |
|
} |
|
if (needsTranslate) { // convert word |
|
result += renderUnicodeWordAsImage(word); |
|
} else { // or just copy across |
|
result += word; |
|
} |
|
return result; |
|
} |
|
|
|
@Override |
|
public void onNotification(NotificationSpec notificationSpec) { |
|
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(), new Long(((long)notificationSpec.getId()<<4) + i + 1)); // wow. This should be easier! |
|
} |
|
try { |
|
JSONObject o = new JSONObject(); |
|
o.put("t", "notify"); |
|
o.put("id", notificationSpec.getId()); |
|
o.put("src", notificationSpec.sourceName); |
|
o.put("title", renderUnicodeAsImage(notificationSpec.title)); |
|
o.put("subject", renderUnicodeAsImage(notificationSpec.subject)); |
|
o.put("body", renderUnicodeAsImage(notificationSpec.body)); |
|
o.put("sender", renderUnicodeAsImage(notificationSpec.sender)); |
|
o.put("tel", notificationSpec.phoneNumber); |
|
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); |
|
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.getEnabled()) 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()); |
|
} |
|
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 onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) { |
|
|
|
} |
|
|
|
@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 onInstallApp(Uri uri) { |
|
|
|
} |
|
|
|
@Override |
|
public void onAppInfoReq() { |
|
|
|
} |
|
|
|
@Override |
|
public void onAppStart(UUID uuid, boolean start) { |
|
|
|
} |
|
|
|
@Override |
|
public void onAppDelete(UUID uuid) { |
|
|
|
} |
|
|
|
@Override |
|
public void onAppConfiguration(UUID appUuid, String config, Integer id) { |
|
|
|
} |
|
|
|
@Override |
|
public void onAppReorder(UUID[] uuids) { |
|
|
|
} |
|
|
|
@Override |
|
public void onFetchRecordedData(int dataTypes) { |
|
if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS) { |
|
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); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
@Override |
|
public void onReset(int flags) { |
|
|
|
} |
|
|
|
@Override |
|
public void onHeartRateTest() { |
|
|
|
} |
|
|
|
@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() { |
|
|
|
} |
|
|
|
@Override |
|
public void onEnableHeartRateSleepSupport(boolean enable) { |
|
|
|
} |
|
|
|
@Override |
|
public void onSetHeartRateMeasurementInterval(int seconds) { |
|
realtimeHRMInterval = seconds; |
|
transmitActivityStatus(); |
|
} |
|
|
|
@Override |
|
public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) { |
|
|
|
} |
|
|
|
@Override |
|
public void onDeleteCalendarEvent(byte type, long id) { |
|
|
|
} |
|
|
|
@Override |
|
public void onSendConfiguration(String config) { |
|
|
|
} |
|
|
|
@Override |
|
public void onReadConfiguration(String config) { |
|
|
|
} |
|
|
|
@Override |
|
public void onTestNewFunction() { |
|
|
|
} |
|
|
|
@Override |
|
public void onSendWeather(WeatherSpec weatherSpec) { |
|
try { |
|
JSONObject o = new JSONObject(); |
|
o.put("t", "weather"); |
|
o.put("temp", weatherSpec.currentTemp); |
|
o.put("hum", weatherSpec.currentHumidity); |
|
o.put("code", weatherSpec.currentConditionCode); |
|
o.put("txt", weatherSpec.currentCondition); |
|
o.put("wind", weatherSpec.windSpeed); |
|
o.put("wdir", weatherSpec.windDirection); |
|
o.put("loc", weatherSpec.location); |
|
uartTxJSON("onSendWeather", o); |
|
} catch (JSONException e) { |
|
LOG.info("JSONException: " + e.getLocalizedMessage()); |
|
} |
|
} |
|
|
|
public Bitmap textToBitmap(String text) { |
|
Paint paint = new Paint(0); // Paint.ANTI_ALIAS_FLAG not wanted as 1bpp |
|
paint.setTextSize(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); |
|
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, |
|
RGB_3BPP |
|
}; |
|
|
|
/** Used for writing single bits to an array */ |
|
public static class BitWriter { |
|
int n; |
|
byte[] bits; |
|
int currentByte, bitIdx; |
|
|
|
public BitWriter(byte[] array, int offset) { |
|
bits = array; |
|
n = offset; |
|
} |
|
|
|
public void push(boolean v) { |
|
currentByte = (currentByte << 1) | (v?1:0); |
|
bitIdx++; |
|
if (bitIdx == 8) { |
|
bits[n++] = (byte)currentByte; |
|
bitIdx = 0; |
|
currentByte = 0; |
|
} |
|
} |
|
|
|
public void finish() { |
|
if (bitIdx > 0) bits[n++] = (byte)currentByte; |
|
} |
|
} |
|
|
|
/** Convert an Android bitmap to a base64 string for use in Espruino. |
|
* Currently only 1bpp, no scaling */ |
|
public static byte[] bitmapToEspruinoArray(Bitmap bitmap, BangleJSBitmapStyle style) { |
|
int width = bitmap.getWidth(); |
|
int height = bitmap.getHeight(); |
|
int bpp = (style==BangleJSBitmapStyle.RGB_3BPP) ? 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 3bpp, check image to see if it's transparent |
|
boolean allowTransparency = (style != BangleJSBitmapStyle.MONOCHROME); |
|
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 (style == BangleJSBitmapStyle.MONOCHROME) |
|
col = ((r+g+b) >= 768)?1:0; |
|
else if (style == BangleJSBitmapStyle.RGB_3BPP) |
|
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; |
|
} |
|
// 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); |
|
} |
|
} |
|
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; |
|
} |
|
}
|
|
|