/* Copyright (C) 2023-2024 Andreas Böhler, foxstidious, Johannes Krude This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.SharedPreferences; import android.os.Handler; import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; 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.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100SampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.Casio2C2DSupport; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_AUTOLIGHT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_AUTOREMOVE_MESSAGE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_CALENDAR; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_CALL; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_EMAIL; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_OTHER; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_SMS; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FAKE_RING_DURATION; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_KEY_VIBRATION; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_OPERATING_SOUNDS; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_PREVIEW_MESSAGE_IN_TITLE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_ACTIVETIME_MINUTES; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DATE_OF_BIRTH; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DISTANCE_METERS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GENDER; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_HEIGHT_CM; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_STEPS_GOAL; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_WEIGHT_KG; public class CasioGBX100DeviceSupport extends Casio2C2DSupport implements SharedPreferences.OnSharedPreferenceChangeListener { private static final Logger LOG = LoggerFactory.getLogger(CasioGBX100DeviceSupport.class); private boolean mGetConfigurationPending = false; private boolean mRingNotificationPending = false; private final ArrayList mSyncedNotificationIDs = new ArrayList<>(); private int mLastCallId = new AtomicInteger((int) (System.currentTimeMillis()/1000)).incrementAndGet(); private int mFakeRingDurationCounter = 0; private final Handler mFindPhoneHandler = new Handler(); private final Handler mFakeRingDurationHandler = new Handler(); private final Handler mAutoRemoveMessageHandler = new Handler(); private final Handler mReconnectHandler = new Handler(); private boolean mNeedsGetConfiguration = false; public CasioGBX100DeviceSupport() { super(LOG); } @Override public DevicePreference[] supportedDevicePreferences() { return new DevicePreference[] { }; } @Override public boolean useAutoConnect() { return true; } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { try { new InitOperation(this, builder, mFirstConnect, mNeedsGetConfiguration).perform(); } catch (IOException e) { GB.toast(getContext(), "Initializing Casio watch failed", Toast.LENGTH_SHORT, GB.ERROR, e); } getDevice().setFirmwareVersion("N/A"); getDevice().setFirmwareVersion2("N/A"); //preferences.registerOnSharedPreferenceChangeListener(this); SharedPreferences prefs = GBApplication.getPrefs().getPreferences(); prefs.registerOnSharedPreferenceChangeListener(this); if(mFirstConnect) { SharedPreferences preferences = GBApplication.getDeviceSpecificSharedPrefs(this.getDevice().getAddress()); SharedPreferences.Editor editor = preferences.edit(); editor.putString(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS, "activity,activitylist,stepsweek"); editor.apply(); } return builder; } @Override public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { UUID characteristicUUID = characteristic.getUuid(); byte[] data = characteristic.getValue(); if(data.length == 0) return true; return super.onCharacteristicRead(gatt, characteristic, status); } public CasioGBX100ActivitySample getSumWithinRange(int timestamp_from, int timestamp_to) { int steps = 0; int calories = 0; try (DBHandler dbHandler = GBApplication.acquireDB()) { User user = DBHelper.getUser(dbHandler.getDaoSession()); Device device = DBHelper.getDevice(this.getDevice(), dbHandler.getDaoSession()); CasioGBX100SampleProvider provider = new CasioGBX100SampleProvider(this.getDevice(), dbHandler.getDaoSession()); List samples = provider.getActivitySamples(timestamp_from, timestamp_to); for(CasioGBX100ActivitySample sample : samples) { if(sample.getDevice().equals(device) && sample.getUser().equals(user)) { steps += sample.getSteps(); calories += sample.getCalories(); } } } catch (Exception e) { LOG.error("Error fetching activity data."); } CasioGBX100ActivitySample ret = new CasioGBX100ActivitySample(); ret.setCalories(calories); ret.setSteps(steps); LOG.debug("Fetched for today: " + calories + " cals and " + steps + " steps."); return ret; } private void addGBActivitySamples(ArrayList samples) { try (DBHandler dbHandler = GBApplication.acquireDB()) { User user = DBHelper.getUser(dbHandler.getDaoSession()); Device device = DBHelper.getDevice(this.getDevice(), dbHandler.getDaoSession()); CasioGBX100SampleProvider provider = new CasioGBX100SampleProvider(this.getDevice(), dbHandler.getDaoSession()); for (CasioGBX100ActivitySample sample : samples) { sample.setDevice(device); sample.setUser(user); sample.setProvider(provider); provider.addGBActivitySample(sample); } } catch (Exception ex) { // Why is this a toast? The user doesn't care about the error. GB.toast(getContext(), "Error saving samples: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); LOG.error(ex.getMessage()); } } public void stepCountDataFetched(int totalCount, int totalCalories, ArrayList data) { LOG.info("Got the following step count data: "); LOG.info("Total Count: {}", totalCount); LOG.info("Total Calories: {}", totalCalories); addGBActivitySamples(data); } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { UUID characteristicUUID = characteristic.getUuid(); byte[] data = characteristic.getValue(); if (data.length == 0) return true; if (characteristicUUID.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID)) { if(data[0] == FEATURE_ALERT_LEVEL) { if(data[1] == 0x02) { onReverseFindDevice(true); } else { onReverseFindDevice(false); } return true; } else if(data[0] == FEATURE_CURRENT_TIME_MANAGER) { if(data[1] == 0x00) { try { TransactionBuilder builder = performInitialized("writeCurrentTime"); writeCurrentTime(builder, ZonedDateTime.now()); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("writing current time failed: " + e.getMessage()); } } } } LOG.info("Unhandled characteristic change: " + characteristicUUID + " code: " + String.format("0x%1x ...", data[0])); return super.onCharacteristicChanged(gatt, characteristic); } public void syncProfile() { try { new SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_ALL).perform(); } catch (IOException e) { GB.toast(getContext(), "Sending Casio configuration failed", Toast.LENGTH_SHORT, GB.ERROR, e); } } /** * Sends a notification to the watch module. Overloaded - this method does not specify a subtitle * since subtitle isn't really used yet. * @param icon Icon to use - see {@link CasioConstants} * @param sender Sender (null if none) * @param title Notification title (null if none) * @param message Message body (null if none) * @param id Notification id * @param delete true if sending a delete of notification to the device */ private void showNotification(byte icon, String sender, String title, String message, int id, boolean delete) { showNotification(icon, sender, title, null, message, id, delete); } /** * Sends a notification to the watch module. Overloaded - this method does not specify a subtitle * @param icon Icon to use - see {@link CasioConstants} * @param sender Sender (null if none) * @param title Notification title (null if none) * @param subtitle Message subtitle (null if none) * @param message Message body (null if none) * @param id Notification id */ private void showNotification(byte icon, String sender, String title, String subtitle, String message, int id, boolean delete) { SharedPreferences sharedPreferences = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); boolean showMessagePreview = sharedPreferences.getBoolean(PREF_PREVIEW_MESSAGE_IN_TITLE, true); boolean shouldAlert = true; switch (icon) { case CasioConstants.CATEGORY_EMAIL: shouldAlert = sharedPreferences.getBoolean(PREF_CASIO_ALERT_EMAIL, true); break; case CasioConstants.CATEGORY_OTHER: shouldAlert = sharedPreferences.getBoolean(PREF_CASIO_ALERT_OTHER, true); break; case CasioConstants.CATEGORY_SNS: shouldAlert = sharedPreferences.getBoolean(PREF_CASIO_ALERT_SMS, true); break; case CasioConstants.CATEGORY_INCOMING_CALL: shouldAlert = sharedPreferences.getBoolean(PREF_CASIO_ALERT_CALL, true); break; case CasioConstants.CATEGORY_SCHEDULE_AND_ALARM: shouldAlert = sharedPreferences.getBoolean(PREF_CASIO_ALERT_CALENDAR, true); break; } // If not a call or email, check the sender and if null, promote the title and message preview // as subtitle if (showMessagePreview && icon != CasioConstants.CATEGORY_INCOMING_CALL && icon != CasioConstants.CATEGORY_EMAIL) { if (StringUtils.isNullOrEmpty(sender)) { // Shift title to sender slot sender = title; } //Shift content to title if (!StringUtils.isNullOrEmpty(message)) { title = message.substring(0, Math.min(message.length(), 18)) + ".."; } } // Make sure title and sender are less than 32 characters byte[] titleBytes = new byte[0]; if (!StringUtils.isNullOrEmpty(title)) { if (title.length() > 32) { title = title.substring(0, 30) + ".."; } titleBytes = title.getBytes(StandardCharsets.UTF_8); } byte[] senderBytes = new byte[0]; if (!StringUtils.isNullOrEmpty(sender)) { if (sender.length() > 32) { sender = sender.substring(0, 30) + ".."; } senderBytes = sender.getBytes(StandardCharsets.UTF_8); } byte[] subtitleBytes = new byte[0]; if (!StringUtils.isNullOrEmpty(subtitle)) { subtitleBytes = subtitle.getBytes(StandardCharsets.UTF_8); } // Ensure the message is not over 250 bytes, as per #4063 // FIXME: We probably need to take the MTU into account too... byte[] messageBytes = new byte[0]; if (!StringUtils.isNullOrEmpty(message)) { messageBytes = StringUtils.truncateToBytes(message, 250); } byte[] arr = new byte[22]; arr[0] = (byte)(id & 0xff); arr[1] = (byte) ((id >> 8) & 0xff); arr[2] = (byte) ((id >> 16) & 0xff); arr[3] = (byte) ((id >> 24) & 0xff); arr[4] = delete ? (byte) 0x02 : (byte) 0x00; if (shouldAlert) { arr[5] = (byte) 0x01; // Set to 0x0 to vibrate/ring for this notification } else { arr[5] = (byte) 0x00; // Set to 0x00 to not vibrate/ring for this notification } arr[6] = icon; SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); String timestamp = dateFormat.format(new Date()); System.arraycopy(timestamp.getBytes(), 0, arr, 7, 15); byte[] copy = Arrays.copyOf(arr, arr.length + 2); copy[copy.length-2] = 0; copy[copy.length-1] = 0; if(senderBytes.length > 0) { copy = Arrays.copyOf(copy, copy.length + senderBytes.length); copy[copy.length-2-senderBytes.length] = (byte)(senderBytes.length & 0xff); copy[copy.length-1-senderBytes.length] = (byte)((senderBytes.length >> 8) & 0xff); System.arraycopy(senderBytes, 0, copy, copy.length - senderBytes.length, senderBytes.length); } copy = Arrays.copyOf(copy, copy.length + 2); copy[copy.length-2] = 0; copy[copy.length-1] = 0; if(titleBytes.length > 0) { copy = Arrays.copyOf(copy, copy.length + titleBytes.length); copy[copy.length-2-titleBytes.length] = (byte)(titleBytes.length & 0xff); copy[copy.length-1-titleBytes.length] = (byte)((titleBytes.length >> 8) & 0xff); System.arraycopy(titleBytes, 0, copy, copy.length - titleBytes.length, titleBytes.length); } copy = Arrays.copyOf(copy, copy.length + 2); // Subtitle copy[copy.length-2] = 0; copy[copy.length-1] = 0; if (subtitleBytes.length > 0) { copy = Arrays.copyOf(copy, copy.length + subtitleBytes.length); copy[copy.length-2-subtitleBytes.length] = (byte)(subtitleBytes.length & 0xff); copy[copy.length-1-subtitleBytes.length] = (byte)((subtitleBytes.length >> 8) & 0xff); System.arraycopy(subtitleBytes, 0, copy, copy.length - subtitleBytes.length, subtitleBytes.length); } copy = Arrays.copyOf(copy, copy.length + 2); copy[copy.length-2] = 0; copy[copy.length-1] = 0; if(messageBytes.length > 0) { copy = Arrays.copyOf(copy, copy.length + messageBytes.length); copy[copy.length-2-messageBytes.length] = (byte)(messageBytes.length & 0xff); copy[copy.length-1-messageBytes.length] = (byte)((messageBytes.length >> 8) & 0xff); System.arraycopy(messageBytes, 0, copy, copy.length - messageBytes.length, messageBytes.length); } for(int i=0; i 100) { mSyncedNotificationIDs.remove(0); } } @Override public void onDeleteNotification(int id) { LOG.info("onDeleteNofication id=" + id); Integer idInt = id; if(mSyncedNotificationIDs.contains(idInt)) { showNotification(CasioConstants.CATEGORY_OTHER, null, null, null, id, true); mSyncedNotificationIDs.remove(idInt); } } private void onReverseFindDevice(boolean start) { if (start) { SharedPreferences sharedPreferences = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); String findPhone = sharedPreferences.getString(PREF_FIND_PHONE, getContext().getString(R.string.p_off)); if(findPhone.equals(getContext().getString(R.string.p_off))) return; GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; evaluateGBDeviceEvent(findPhoneEvent); if(findPhone.equals(getContext().getString(R.string.p_on))) { String duration = sharedPreferences.getString(PREF_FIND_PHONE_DURATION, "0"); try { int iDuration; try { iDuration = Integer.parseInt(duration); } catch (Exception ex) { LOG.warn(ex.getMessage()); iDuration = 60; } if(iDuration > 0) { this.mFindPhoneHandler.postDelayed(new Runnable() { @Override public void run() { GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; evaluateGBDeviceEvent(findPhoneEvent); } }, iDuration * 1000); } } catch (Exception e) { LOG.error("Unexpected exception in MiBand2Coordinator.getTime: " + e.getMessage()); } } } else { // Always send stop, ignore preferences. GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; evaluateGBDeviceEvent(findPhoneEvent); } } @Override public void onSetAlarms(ArrayList alarms) { byte[] data1 = new byte[5]; byte[] data2 = new byte[17]; if(!isConnected()) return; data1[0] = FEATURE_SETTING_FOR_ALM; data2[0] = FEATURE_SETTING_FOR_ALM2; for(int i=0; i