Garmin: Display fetch and parse progress

This commit is contained in:
José Rebelo
2025-07-21 19:54:37 +01:00
parent a0432afd57
commit 62dbbea218
7 changed files with 386 additions and 111 deletions
@@ -120,6 +120,8 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP
currentlyDownloading.append(fileTransferDataMessage);
if (!currentlyDownloading.dataHolder.hasRemaining())
processCompleteDownload();
else
deviceSupport.onFileDownloadProgress(currentlyDownloading.dataHolder.position());
}
private void processCompleteDownload() {
@@ -397,6 +399,10 @@ public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYP
return fileDate;
}
public int getFileSize() {
return fileSize;
}
/**
* Builds the output path.
* Format: [FILE_TYPE]/[YEAR]/[FILE_TYPE]_[yyyy-MM-dd_HH-mm-ss]_[INDEX].[fit/bin]
@@ -93,6 +93,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS;
@@ -111,6 +112,8 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
private boolean mFirstConnect = false;
private boolean isBusyFetching;
private GBProgressNotification transferNotification;
final Map<UUID, GdiInstalledAppsService.InstalledAppsService.InstalledApp> installedApps = new HashMap<>();
public GarminSupport() {
@@ -132,6 +135,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
public void setContext(final GBDevice gbDevice, final BluetoothAdapter btAdapter, final Context context) {
super.setContext(gbDevice, btAdapter, context);
this.mediaManager = new MediaManager(context);
this.transferNotification = new GBProgressNotification(context, GB.NOTIFICATION_CHANNEL_ID_TRANSFER);
}
@Override
@@ -143,8 +147,15 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
}
}
public void onFileDownloadProgress(final int progress) {
transferNotification.setChunkProgress(progress);
}
public void addFileToDownloadList(FileTransferHandler.DirectoryEntry directoryEntry) {
filesToDownload.add(directoryEntry);
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.incrementTotalSize(directoryEntry.getFileSize());
}
}
@Override
@@ -332,13 +343,13 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
final FileTransferHandler.DirectoryEntry entry = ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry;
final String filename = entry.getFileName();
LOG.debug("FILE DOWNLOAD COMPLETE {}", filename);
transferNotification.incrementTotalProgress(entry.getFileSize());
if (entry.getFiletype().isFitFile()) {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final PendingFileProvider pendingFileProvider = new PendingFileProvider(gbDevice, session);
pendingFileProvider.addPendingFile(((FileDownloadedDeviceEvent) deviceEvent).localPath);
} catch (final Exception e) {
GB.toast(getContext(), "Error saving pending file", Toast.LENGTH_LONG, GB.ERROR, e);
@@ -617,7 +628,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
if (!filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (!gbDevice.isBusy()) {
isBusyFetching = true;
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
transferNotification.start(R.string.busy_task_fetch_activity_data, 0);
getDevice().setBusyTask(R.string.busy_task_fetch_activity_data, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
@@ -629,11 +640,17 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
if (!getKeepActivityDataOnDevice()) { // delete file from watch if already downloaded
sendOutgoingMessage("archive file " + directoryEntry.getFileIndex(), new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.incrementTotalProgress(directoryEntry.getFileSize());
}
continue;
}
final DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry);
LOG.debug("Will download file: {}", directoryEntry.getOutputPath());
if (directoryEntry.getFiletype() != FileType.FILETYPE.DIRECTORY) {
transferNotification.setChunkProgress(0);
}
sendOutgoingMessage("download file " + directoryEntry.getFileIndex(), downloadRequestMessage);
return;
}
@@ -661,7 +678,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
if (gbDevice.isBusy() && isBusyFetching) {
getDevice().unsetBusyTask();
GB.signalActivityDataFinish(getDevice());
GB.updateTransferNotification(null, "", false, 100, getContext());
transferNotification.finish();
getDevice().sendDeviceUpdateIntent(getContext());
}
isBusyFetching = false;
@@ -672,27 +689,21 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
// isBusyFetching so we do not start multiple processors
isBusyFetching = false;
transferNotification.start(R.string.busy_task_processing_files, 0);
transferNotification.setTotalSize(filesToProcess.size());
final FitAsyncProcessor fitAsyncProcessor = new FitAsyncProcessor(getContext(), getDevice());
final long[] lastNotificationUpdateTs = new long[]{System.currentTimeMillis()};
fitAsyncProcessor.process(filesToProcess, new FitAsyncProcessor.Callback() {
@Override
public void onProgress(final int i) {
final long now = System.currentTimeMillis();
if (now - lastNotificationUpdateTs[0] > 1500L) {
lastNotificationUpdateTs[0] = now;
GB.updateTransferNotification(
"Parsing fit files", "File " + i + " of " + filesToProcess.size(),
true,
(i * 100) / filesToProcess.size(), getContext()
);
}
transferNotification.setTotalProgress(i);
}
@Override
public void onFinish() {
getDevice().unsetBusyTask();
GB.signalActivityDataFinish(getDevice());
GB.updateTransferNotification(null, "", false, 100, getContext());
transferNotification.finish();
getDevice().sendDeviceUpdateIntent(getContext());
}
});
@@ -1053,7 +1064,8 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
GB.toast(getContext(), "Check notification for progress", Toast.LENGTH_LONG, GB.INFO);
GB.updateTransferNotification("Parsing fit files", "...", true, 0, getContext());
transferNotification.start(R.string.busy_task_processing_files, 0);
transferNotification.setTotalSize(fitFiles.size());
//try (DBHandler handler = GBApplication.acquireDB()) {
// final DaoSession session = handler.getDaoSession();
@@ -1063,26 +1075,17 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
// GB.toast(getContext(), "Error deleting activity data", Toast.LENGTH_LONG, GB.ERROR, e);
//}
final long[] lastNotificationUpdateTs = new long[]{System.currentTimeMillis()};
final FitAsyncProcessor fitAsyncProcessor = new FitAsyncProcessor(getContext(), getDevice());
fitAsyncProcessor.process(fitFiles, new FitAsyncProcessor.Callback() {
@Override
public void onProgress(final int i) {
final long now = System.currentTimeMillis();
if (now - lastNotificationUpdateTs[0] > 1500L) {
lastNotificationUpdateTs[0] = now;
GB.updateTransferNotification(
"Parsing fit files", "File " + i + " of " + fitFiles.size(),
true,
(i * 100) / fitFiles.size(), getContext()
);
}
transferNotification.setTotalProgress(i);
}
@Override
public void onFinish() {
parsingFitFilesFromStorage = false;
GB.updateTransferNotification("", "", false, 100, getContext());
transferNotification.finish();
GB.signalActivityDataFinish(getDevice());
}
});
@@ -1,82 +0,0 @@
/* Copyright (C) 2024 José Rebelo
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.test;
import android.os.Handler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestFeature;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport;
public class TestDeviceSupport extends AbstractDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(TestDeviceSupport.class);
private final Handler handler = new Handler();
@Override
public boolean connect() {
LOG.info("Connecting");
getDevice().setUpdateState(GBDevice.State.CONNECTING, getContext());
handler.postDelayed(() -> {
LOG.info("Initialized");
getDevice().setFirmwareVersion("1.0.0");
getDevice().setFirmwareVersion2("N/A");
getDevice().setModel("0.1.7");
if (getCoordinator().supportsLedColor()) {
getDevice().setExtraInfo("led_color", 0xff3061e3);
} else {
getDevice().setExtraInfo("led_color", null);
}
if (getCoordinator().supports(getDevice(), TestFeature.FM_FREQUENCY)) {
getDevice().setExtraInfo("fm_frequency", 90.2f);
} else {
getDevice().setExtraInfo("fm_frequency", null);
}
// TODO battery percentages
// TODO hr measurements
// TODO app list
// TODO screenshots
getDevice().setUpdateState(GBDevice.State.INITIALIZED, getContext());
}, 1000);
return true;
}
@Override
public void dispose() {
}
@Override
public boolean useAutoConnect() {
return false;
}
protected TestDeviceCoordinator getCoordinator() {
return (TestDeviceCoordinator) getDevice().getDeviceCoordinator();
}
}
@@ -0,0 +1,107 @@
/* Copyright (C) 2025 José Rebelo
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.test
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.os.Handler
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestFeature
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification
import org.slf4j.Logger
import org.slf4j.LoggerFactory
open class TestDeviceSupport : AbstractDeviceSupport() {
private lateinit var progressNotification: GBProgressNotification
private val handler = Handler()
override fun setContext(gbDevice: GBDevice, btAdapter: BluetoothAdapter, context: Context) {
super.setContext(gbDevice, btAdapter, context)
progressNotification = GBProgressNotification(context, GB.NOTIFICATION_CHANNEL_ID_TRANSFER)
}
override fun connect(): Boolean {
LOG.info("Connecting")
device.setUpdateState(GBDevice.State.CONNECTING, context)
handler.postDelayed({
LOG.info("Initialized")
device.firmwareVersion = "1.0.0"
device.firmwareVersion2 = "N/A"
device.model = "0.1.7"
if (this.coordinator.supportsLedColor()) {
device.setExtraInfo("led_color", 0xff3061e3.toInt())
} else {
device.setExtraInfo("led_color", null)
}
if (this.coordinator.supports(device, TestFeature.FM_FREQUENCY)) {
device.setExtraInfo("fm_frequency", 90.2f)
} else {
device.setExtraInfo("fm_frequency", null)
}
// TODO battery percentages
// TODO hr measurements
// TODO app list
// TODO screenshots
device.setUpdateState(GBDevice.State.INITIALIZED, context)
}, 1000)
return true
}
override fun dispose() {
handler.removeCallbacksAndMessages(null)
}
override fun useAutoConnect(): Boolean {
return false
}
override fun onFetchRecordedData(dataTypes: Int) {
LOG.debug("onFetchRecordedData - simulating progress")
progressNotification.start(R.string.busy_task_fetch_activity_data, 0)
device.setBusyTask(R.string.busy_task_fetch_activity_data, context)
device.sendDeviceUpdateIntent(context)
progressNotification.setTotalSize(100)
handler.postDelayed({ progressNotification.setChunkProgress(20) }, 1000L)
handler.postDelayed({ progressNotification.setChunkProgress(40) }, 2000L)
handler.postDelayed({ progressNotification.setTotalProgress(60) }, 3000L)
handler.postDelayed({ progressNotification.setTotalProgress(80) }, 4000L)
handler.postDelayed({
progressNotification.finish()
device.unsetBusyTask()
device.sendDeviceUpdateIntent(context)
}, 5000L)
}
protected val coordinator: TestDeviceCoordinator
get() = device.deviceCoordinator as TestDeviceCoordinator
companion object {
private val LOG: Logger = LoggerFactory.getLogger(TestDeviceSupport::class.java)
}
}
@@ -103,7 +103,6 @@ public class GB {
/** Commands related to the progress (bar) on the screen */
public static final String ACTION_SET_PROGRESS_BAR = "GB_Set_Progress_Bar";
public static final String PROGRESS_BAR_INDETERMINATE = "indeterminate";
public static final String PROGRESS_BAR_MAX = "max";
public static final String PROGRESS_BAR_PROGRESS = "progress";
public static final String ACTION_SET_PROGRESS_TEXT = "GB_Set_Progress_Text";
public static final String ACTION_SET_INFO_TEXT = "GB_Set_Info_Text";
@@ -111,8 +110,6 @@ public class GB {
private static boolean notificationChannelsCreated;
private static final String TAG = "GB";
public static void createNotificationChannels(Context context) {
if (notificationChannelsCreated) return;
@@ -541,6 +538,10 @@ public class GB {
return nb.build();
}
/**
* @deprecated use {@link nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification}
*/
@Deprecated
public static void updateTransferNotification(CharSequence title, CharSequence text, boolean ongoing, int percentage, Context context) {
if (percentage == 100) {
removeNotification(NOTIFICATION_ID_TRANSFER, context);
@@ -0,0 +1,238 @@
/* Copyright (C) 2025 José Rebelo
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.util.notifications
import android.app.Notification
import android.content.Context
import android.content.Intent
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import nodomain.freeyourgadget.gadgetbridge.BuildConfig
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.Random
import kotlin.math.roundToInt
/**
* A helper class to keep a transfer notification updated, with utility functions to handle throttling and progress.
* <p>
* It is assumed that a chunk is in the same unit as the total progress, and not included in it until the chunk finishes.
* At any point, the progress percentage of the notification corresponds to (totalProgress + chunkProgress) / totalSize.
*/
class GBProgressNotification(
private val context: Context,
private val channelId: String
) {
private val notificationId: Int = Random().nextInt(100000)
@StringRes
private var titleRes = 0
@StringRes
private var textRes = 0
private var visible = false
private var lastNotificationUpdateTs: Long = -1
private var totalProgress: Long = 0
private var totalSize: Long = 0
private var chunkProgress: Long = 0
/**
* Starts the transfer notification, resetting all progress and size to zero.
*/
fun start(
@StringRes titleRes: Int,
@StringRes textRes: Int
) {
this.titleRes = titleRes
this.textRes = textRes
this.chunkProgress = 0
this.totalProgress = 0
this.totalSize = 0
LOG.debug(
"Started notification id={}, title={}",
notificationId,
if (titleRes != 0) context.getString(titleRes) else "0"
)
refresh(true)
}
fun setChunkProgress(chunkProgress: Long) {
LOG.debug("setChunkProgress id={}: {}", notificationId, chunkProgress)
this.chunkProgress = chunkProgress
refresh(false)
}
fun setTotalProgress(totalProgress: Long) {
LOG.debug("setTotalProgress id={}: {}", notificationId, totalProgress)
this.chunkProgress = 0
this.totalProgress = totalProgress
refresh(false)
}
fun setTotalSize(totalSize: Long) {
LOG.debug("setTotalSize id={}: {}", notificationId, totalSize)
this.totalSize = totalSize
refresh(false)
}
fun incrementTotalProgress(inc: Long) {
LOG.debug("incrementTotalProgress id={}: {}", notificationId, inc)
this.chunkProgress = 0
this.totalProgress += inc
refresh(false)
}
fun incrementTotalSize(inc: Long) {
LOG.debug("incrementTotalSize id={}: {}", notificationId, inc)
this.totalSize += inc
refresh(false)
}
fun setProgress(chunkProgress: Long, totalProgress: Long) {
LOG.debug("setProgress id={}: {} {}", notificationId, chunkProgress, totalProgress)
this.chunkProgress = chunkProgress
this.totalProgress = totalProgress
refresh(false)
}
fun getProgressPercentage(): Int {
var percentage = 0f
if (totalSize != 0L) {
percentage = ((totalProgress * 100) / totalSize.toFloat())
if (chunkProgress != 0L) {
percentage += ((chunkProgress * 100) / totalSize.toFloat())
}
}
return percentage.roundToInt()
}
fun finish() {
visible = false
LOG.debug("Finishing notification id={}", notificationId)
GB.removeNotification(notificationId, context)
}
private fun refresh(force: Boolean) {
val percentage = getProgressPercentage()
LOG.debug("Updating notification, percentage={}", percentage)
if (visible) {
val now = System.currentTimeMillis()
if (now - lastNotificationUpdateTs < MIN_TIME_BETWEEN_UPDATES && !force) {
LOG.debug("Throttling notification update")
return
}
lastNotificationUpdateTs = now
}
visible = true
val title: CharSequence = if (titleRes != 0) context.getString(titleRes) else "Unknown transfer"
val text = if (textRes != 0) {
context.getString(textRes)
} else if (totalSize != 0L) {
context.getString(R.string.busy_task_progress_int, totalProgress + chunkProgress, totalSize)
} else {
""
}
update(
title = title,
text = text,
ongoing = true,
percentage = percentage
)
}
private fun update(
title: CharSequence,
text: CharSequence,
ongoing: Boolean,
percentage: Int
) {
if (percentage >= 100) {
GB.removeNotification(notificationId, context)
} else {
val notification: Notification = createProgressNotification(
title, text, ongoing, percentage, channelId, context
)
GB.notify(notificationId, notification, context)
}
}
companion object {
private val LOG: Logger = LoggerFactory.getLogger(GBProgressNotification::class.java)
const val MIN_TIME_BETWEEN_UPDATES = 1000L
private fun createProgressNotification(
title: CharSequence?,
text: CharSequence?,
ongoing: Boolean,
percentage: Int,
channelId: String,
context: Context
): Notification {
val notificationIntent = Intent(context, ControlCenterv2::class.java)
notificationIntent.setPackage(BuildConfig.APPLICATION_ID)
notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val pendingIntent = PendingIntentUtils.getActivity(
context,
0,
notificationIntent,
0,
false
)
val nb = NotificationCompat.Builder(context, channelId)
.setTicker(title ?: context.getString(R.string.app_name))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(title ?: context.getString(R.string.app_name))
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentText(text)
.setContentIntent(pendingIntent)
.setOnlyAlertOnce(percentage > 0 && percentage < 100)
.setOngoing(ongoing)
if (ongoing) {
nb.setProgress(100, percentage, percentage == 0)
nb.setSmallIcon(android.R.drawable.stat_sys_download)
} else {
nb.setProgress(0, 0, false)
nb.setSmallIcon(android.R.drawable.stat_sys_download_done)
}
return nb.build()
}
}
}
+2
View File
@@ -747,6 +747,8 @@
<string name="busy_task_fetch_weight_data">Fetching weight data</string>
<string name="busy_task_fetch_training_data">Fetching last training data</string>
<string name="busy_task_fetch_training_data_finished">Training data received!</string>
<string name="busy_task_processing_files">Processing files</string>
<string name="busy_task_progress_int">%1$d of %2$d</string>
<string name="busy_task_busy">Busy…</string>
<string name="busy_task_configuring">Configuring</string>
<string name="busy_task_downloading">Downloading</string>