/* Copyright (C) 2023-2024 José Rebelo, Yoran Vulker 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.xiaomi.activity; import android.content.Context; import android.os.Handler; import android.os.Looper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; import java.util.Arrays; import java.util.List; import java.util.PriorityQueue; import java.util.Queue; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services.XiaomiHealthService; import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class XiaomiActivityFileFetcher { private static final Logger LOG = LoggerFactory.getLogger(XiaomiActivityFileFetcher.class); private final XiaomiHealthService mHealthService; private final Queue mFetchQueue = new PriorityQueue<>(); private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream(); private boolean isFetching = false; private final Handler timeoutHandler = new Handler(Looper.getMainLooper()); public XiaomiActivityFileFetcher(final XiaomiHealthService healthService) { this.mHealthService = healthService; } public void dispose() { clearTimeout(); } private void clearTimeout() { this.timeoutHandler.removeCallbacksAndMessages(null); } private void setTimeout() { // #4305 - Set the timeout in case the watch does not send the file this.timeoutHandler.postDelayed(() -> { LOG.warn("Timed out waiting for activity file with {} bytes in the buffer", mBuffer.size()); triggerNextFetch(); }, 5000L); } public void addChunk(final byte[] chunk) { clearTimeout(); final int total = BLETypeConversions.toUint16(chunk, 0); final int num = BLETypeConversions.toUint16(chunk, 2); if (num == 1) { // reset buffer mBuffer = new ByteArrayOutputStream(); } LOG.debug("Got activity chunk {}/{}", num, total); mBuffer.write(chunk, 4, chunk.length - 4); if (num != total) { setTimeout(); return; } final byte[] data = mBuffer.toByteArray(); mBuffer = new ByteArrayOutputStream(); if (data.length < 13) { LOG.warn("Activity data length of {} is too short", data.length); // FIXME this may mess up the order.. maybe we should just abort triggerNextFetch(); return; } final int arrCrc32 = CheckSums.getCRC32(data, 0, data.length - 4); final int expectedCrc32 = BLETypeConversions.toUint32(data, data.length - 4); if (arrCrc32 != expectedCrc32) { LOG.warn( "Invalid activity data checksum: got {}, expected {}", String.format("%08X", arrCrc32), String.format("%08X", expectedCrc32) ); // FIXME this may mess up the order.. maybe we should just abort triggerNextFetch(); return; } if (data[7] != 0) { LOG.warn( "Unexpected activity payload byte {} at position 7 - parsing might fail", String.format("0x%02X", data[7]) ); } final byte[] fileIdBytes = Arrays.copyOfRange(data, 0, 7); final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(fileIdBytes); dumpBytesToExternalStorage(fileId, data); if (!XiaomiPreferences.keepActivityDataOnDevice(mHealthService.getSupport().getDevice())) { LOG.debug("Acking recorded data {}", fileId); // TODO is this too early? mHealthService.ackRecordedData(fileId); } final XiaomiActivityParser activityParser = XiaomiActivityParser.create(fileId); if (activityParser == null) { LOG.warn("Failed to find parser for {}", fileId); triggerNextFetch(); return; } try { if (activityParser.parse(mHealthService.getSupport(), fileId, data)) { LOG.info("Successfully parsed {}", fileId); } else { LOG.warn("Failed to parse {}", fileId); } } catch (final Exception ex) { LOG.error("Exception while parsing {}", fileId, ex); } triggerNextFetch(); } public void fetch(final List fileIds) { // #4305 - ensure unique files for (final XiaomiActivityFileId fileId : fileIds) { if (!mFetchQueue.contains(fileId)) { mFetchQueue.add(fileId); } else { LOG.warn("Ignoring duplicated file {}", fileId); } } if (!isFetching) { // Currently not fetching anything, fetch the next isFetching = true; final XiaomiSupport support = mHealthService.getSupport(); final Context context = support.getContext(); GB.updateTransferNotification(context.getString(R.string.busy_task_fetch_activity_data), "", true, 0, context); support.getDevice().setBusyTask(context.getString(R.string.busy_task_fetch_activity_data)); support.getDevice().sendDeviceUpdateIntent(support.getContext()); triggerNextFetch(); } } private void triggerNextFetch() { clearTimeout(); mBuffer = new ByteArrayOutputStream(); final XiaomiActivityFileId fileId = mFetchQueue.poll(); if (fileId == null) { LOG.debug("Nothing more to fetch"); isFetching = false; mHealthService.getSupport().getDevice().unsetBusyTask(); GB.signalActivityDataFinish(mHealthService.getSupport().getDevice()); GB.updateTransferNotification(null, "", false, 100, mHealthService.getSupport().getContext()); mHealthService.getSupport().getDevice().sendDeviceUpdateIntent(mHealthService.getSupport().getContext()); return; } LOG.debug("Triggering next fetch for: {}", fileId); setTimeout(); mHealthService.requestRecordedData(fileId); } protected void dumpBytesToExternalStorage(final XiaomiActivityFileId fileId, final byte[] bytes) { try { final GBDevice device = mHealthService.getSupport().getDevice(); final File exportDirectory = device.getDeviceCoordinator().getWritableExportDirectory(device); final File targetDir = new File(exportDirectory, "rawFetchOperations"); targetDir.mkdirs(); final File outputFile = new File(targetDir, fileId.getFilename()); final OutputStream outputStream = new FileOutputStream(outputFile); outputStream.write(bytes); outputStream.close(); } catch (final Exception e) { LOG.error("Failed to dump bytes to storage", e); } } }