From c6cec7a0f8fe263800339105a2500e0819de1197 Mon Sep 17 00:00:00 2001 From: Ganblejs Date: Sat, 17 Jun 2023 16:28:31 +0200 Subject: [PATCH] Bangle.js:WIP add activity tracks support Bangle.js: WIP add supportsActivityTracks Bangle.js: testing flow of info Bangle.js:WIP receive and store csv from Bangle.js Bangle.js:store and transmit ID of last synced log bangle.js:activity tracks, act on completed fetch ... of the recorder csv file. Bangle.js: Activity tracks, now in database ... but not all data is persisted correctly I think. It's presented as 'Unknown activity'. Bangle.js:Activity tracks, try to add gps info I haven't tested with recordings where I have gps values, so far only empty values. With empty values I currently get "This activity does not contain GPX tracks" when trying to use the GPXExporter. Bangle.js: Activity tracks, now adds GPS points ... to the activity to be shown when on the "Sport Activity Detail" screen. --- .../devices/banglejs/BangleJSCoordinator.java | 5 + .../banglejs/BangleJSDeviceSupport.java | 276 +++++++++++++++++- 2 files changed, 279 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java index c1a84c99f..f81f31e3b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java @@ -107,6 +107,11 @@ public class BangleJSCoordinator extends AbstractBLEDeviceCoordinator { return true; } + @Override + public boolean supportsActivityTracks() { + return true; + } + @Override public boolean supportsScreenshots() { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java index 4f29e1f1b..74bd73571 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java @@ -19,6 +19,7 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs; +import static java.sql.Date.valueOf; 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; @@ -67,8 +68,11 @@ import org.slf4j.LoggerFactory; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; +import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.StringReader; @@ -76,6 +80,7 @@ import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -84,6 +89,7 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.SimpleTimeZone; import javax.xml.xpath.XPath; @@ -96,6 +102,7 @@ import io.wax911.emojify.EmojiManager; import io.wax911.emojify.EmojiUtils; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.BarcodeFormat; import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard; @@ -108,22 +115,33 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicContr import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; 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.BaseActivitySummary; 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.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter; +import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter; 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.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; 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.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec; @@ -136,6 +154,8 @@ 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.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.EmojiConverter; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -562,6 +582,206 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport { case "act": handleActivity(json); break; + case "trksList": + { + LOG.info("trksList says hi!"); + GB.toast(getContext(), "trksList says hi!", Toast.LENGTH_LONG, GB.INFO); + JSONArray tracksList = json.getJSONArray("list"); + LOG.info("New recorder logs since last fetch: " + String.valueOf(tracksList)); + for (int i = 0; i < tracksList.length(); i ++) { + requestActivityTrackLog(tracksList.getString(i)); + } + }; + break; + case "actTrk": + { + LOG.info("actTrk says hi!"); + //GB.toast(getContext(), "actTrk says hi!", Toast.LENGTH_LONG, GB.INFO); + String log = json.getString("log"); + String line = json.getString("line"); + LOG.info(log); + LOG.info(line); + File dir; + try { + dir = FileUtils.getExternalFilesDir(); + } catch (IOException e) { + return; + } + String filename = "recorder.log" + log + ".csv"; + + if (line.equals("end of recorder log")) { // TODO: Persist log to database here by reading the now completely transferred csv file from GB storage directory + + File inputFile = new File(dir, filename); + try { // FIXME: There is maybe code inside this try-statement that should be outside of it. + + // Read from the previously stored log (see the else-statement below) into a string. + BufferedReader reader = new BufferedReader(new FileReader(inputFile)); + StringBuilder storedLogBuilder = new StringBuilder(reader.readLine() + "\n"); + while ((line = reader.readLine()) != null) { + storedLogBuilder.append(line).append("\n"); + } + reader.close(); + String storedLog = String.valueOf(storedLogBuilder); + storedLog = storedLog.replace(",",", "); // So all rows (internal arrays) in storedLogArray2 get the same number of entries. + LOG.info("Contents of log read from GB storage:\n" + storedLog); + + // Turn the string log into a 2d array in two steps. + String[] storedLogArray = storedLog.split("\n") ; + String[][] storedLogArray2 = new String[storedLogArray.length][1]; + + for (int i = 0; i < storedLogArray.length; i++) { + storedLogArray2[i] = storedLogArray[i].split(","); + for (int j = 0; j < storedLogArray2[i].length;j++) { + storedLogArray2[i][j] = storedLogArray2[i][j].trim(); // Remove the extra spaces we introduced above for getting the same number of entries on all rows. + } + } + + LOG.info("Contents of storedLogArray2:\n" + Arrays.deepToString(storedLogArray2)); + + // Turn the 2d array into an object for easier access later on. + JSONObject storedLogObject = new JSONObject(); + JSONArray valueArray = new JSONArray(); + for (int i = 0; i < storedLogArray2[0].length; i++){ + for (int j = 1; j < storedLogArray2.length; j++) { + valueArray.put(storedLogArray2[j][i]); + } + storedLogObject.put(storedLogArray2[0][i], valueArray); + valueArray = new JSONArray(); + } + + LOG.info("storedLogObject:\n" + storedLogObject); + + BaseActivitySummary summary = null; + + Date startTime = new Date(Long.parseLong(storedLogArray2[1][0])*1000L); + Date endTime = new Date(Long.parseLong(storedLogArray2[storedLogArray2.length-1][0])*1000L); + summary = new BaseActivitySummary(); + summary.setName(log); + summary.setStartTime(startTime); + summary.setEndTime(endTime); + summary.setActivityKind(ActivityKind.TYPE_RUNNING); // TODO: Make this depend on info from watch (currently this info isn't supplied in Bangle.js recorder logs). + summary.setRawDetailsPath(String.valueOf(inputFile)); + summary.setSummaryData(storedLog); + + ActivityTrack track = new ActivityTrack(); // detailsParser.parse(buffer.toByteArray()); + track.startNewSegment(); + track.setBaseTime(startTime); + track.setName(log); + try (DBHandler dbHandler = GBApplication.acquireDB()) { + DaoSession session = dbHandler.getDaoSession(); + Device device = DBHelper.getDevice(getDevice(), session); + User user = DBHelper.getUser(session); + track.setDevice(device); + track.setUser(user); + } catch (Exception ex) { + GB.toast(getContext(), "Error setting user for activity track.", Toast.LENGTH_LONG, GB.ERROR, ex); + } + ActivityPoint point = new ActivityPoint(); + Date timeOfPoint = new Date(); + for (int i = 0; i < storedLogObject.getJSONArray("Time").length(); i++) { + timeOfPoint.setTime(storedLogObject.getJSONArray("Time").getLong(i)*1000L); + point.setTime(timeOfPoint); + if (storedLogObject.has("Longitude")) { + if (!Objects.equals(storedLogObject.getJSONArray("Longitude").getString(i), "") + && !Objects.equals(storedLogObject.getJSONArray("Latitude").getString(i), "") + && !Objects.equals(storedLogObject.getJSONArray("Altitude").getString(i), "")) { + + point.setLocation(new GPSCoordinate( + storedLogObject.getJSONArray("Longitude").getDouble(i), + storedLogObject.getJSONArray("Latitude").getDouble(i), + storedLogObject.getJSONArray("Altitude").getDouble(i) + ) + ); + } + } + if (storedLogObject.has("Heartrate") && !Objects.equals(storedLogObject.getJSONArray("Heartrate").getString(i), "")) { + point.setHeartRate(storedLogObject.getJSONArray("Heartrate").getInt(i)); + } + track.addTrackPoint(point); + point = new ActivityPoint(); + } + + ActivityTrackExporter exporter = createExporter(); + String trackType = "track"; + switch (summary.getActivityKind()) { + case ActivityKind.TYPE_CYCLING: + trackType = getContext().getString(R.string.activity_type_biking); + break; + case ActivityKind.TYPE_RUNNING: + trackType = getContext().getString(R.string.activity_type_running); + break; + case ActivityKind.TYPE_WALKING: + trackType = getContext().getString(R.string.activity_type_walking); + break; + case ActivityKind.TYPE_HIKING: + trackType = getContext().getString(R.string.activity_type_hiking); + break; + case ActivityKind.TYPE_CLIMBING: + trackType = getContext().getString(R.string.activity_type_climbing); + break; + case ActivityKind.TYPE_SWIMMING: + trackType = getContext().getString(R.string.activity_type_swimming); + break; + } + + String fileName = FileUtils.makeValidFileName("gadgetbridge-" + trackType.toLowerCase() + "-" + summary.getName() + ".gpx"); + File targetFile = new File(FileUtils.getExternalFilesDir(), fileName); + + try { + exporter.performExport(track, targetFile); + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + summary.setGpxTrack(targetFile.getAbsolutePath()); + //dbHandler.getDaoSession().getBaseActivitySummaryDao().update(summary); + } catch (Exception e) { + throw new RuntimeException(e); + } + } catch (ActivityTrackExporter.GPXTrackEmptyException ex) { + GB.toast(getContext(), "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex); + } + + //summary.setSummaryData(null); // remove json before saving to database, + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + DaoSession session = dbHandler.getDaoSession(); + Device device = DBHelper.getDevice(getDevice(), session); + User user = DBHelper.getUser(session); + summary.setDevice(device); + summary.setUser(user); + session.getBaseActivitySummaryDao().insertOrReplace(summary); + } catch (Exception ex) { + GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, ex); + } + + + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else { // We received a line of the csv, now we append it to the file in storage. + // TODO: File manipulation adapted from onFetchRecordedData() - break out to a new function to avoid code duplication? + + File outputFile = new File(dir, filename); + String filenameLogID = "latestFetchedRecorderLog.txt"; + File outputFileLogID = new File(dir, filenameLogID); + LOG.warn("Writing log to " + outputFile.toString()); + try { + BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile, true)); + writer.write(line); + writer.close(); + GB.toast(getContext(), "Log written to " + filename, Toast.LENGTH_LONG, GB.INFO); + + BufferedWriter writerLogID = new BufferedWriter(new FileWriter(outputFileLogID)); + writerLogID.write(log); + writerLogID.close(); + GB.toast(getContext(), "Log ID " + log + " written to " + filenameLogID, Toast.LENGTH_LONG, GB.INFO); + } catch (IOException e) { + LOG.warn("Could not write to file", e); + } + } + }; + break; case "http": handleHttp(json); break; @@ -1375,6 +1595,28 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport { } } + private void requestActivityTracksList(String lastSyncedID) { + try { + JSONObject o = new JSONObject(); + o.put("t", "listRecs"); + o.put("id", lastSyncedID); + uartTxJSON("requestActivityTracksList", o); + } catch (JSONException e) { + LOG.info("JSONException: " + e.getLocalizedMessage()); + } + } + + private void requestActivityTrackLog(String id) { + try { + JSONObject o = new JSONObject(); + o.put("t", "fetchRec"); + o.put("id", id); + uartTxJSON("requestActivityTrackLog", o); + } catch (JSONException e) { + LOG.info("JSONException: " + e.getLocalizedMessage()); + } + } + @Override public void onEnableRealtimeSteps(boolean enable) { if (enable == realtimeHRM) return; @@ -1388,7 +1630,30 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport { fetchActivityData(getLastSuccessfulSyncTime()); } - if ((dataTypes & RecordedDataTypes.TYPE_DEBUGLOGS) != 0) { + if (dataTypes == RecordedDataTypes.TYPE_GPS_TRACKS) { + GB.toast("TYPE_GPS_TRACKS says hi!", Toast.LENGTH_LONG, GB.INFO); + File dir; + try { + dir = FileUtils.getExternalFilesDir(); + } catch (IOException e) { + return; + } + String filename = "latestFetchedRecorderLog.txt"; + File inputFile = new File(dir, filename); + BufferedReader reader; + String lastSyncedID = ""; + try { + reader = new BufferedReader(new FileReader(inputFile)); + lastSyncedID = String.valueOf(reader.readLine()); + reader.close(); + } catch (IOException ignored) { + } + //lastSyncedID = "20230706x"; // DEBUGGING + + LOG.info("Last Synced log ID: " + lastSyncedID); + requestActivityTracksList(lastSyncedID); + } + if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS) { File dir; try { dir = FileUtils.getExternalFilesDir(); @@ -1397,7 +1662,7 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport { } SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US); String filename = "banglejs_debug_" + dateFormat.format(new Date()) + ".log"; - File outputFile = new File(dir, filename ); + File outputFile = new File(dir, filename); LOG.warn("Writing log to "+outputFile.toString()); try { BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile)); @@ -1816,4 +2081,11 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport { LOG.info("JSONException: " + e.getLocalizedMessage()); } } + + private ActivityTrackExporter createExporter() { + GPXExporter exporter = new GPXExporter(); + exporter.setCreator(GBApplication.app().getNameAndVersion()); + return exporter; + } + }