From b49fe1730c901de41e45018d2d1a212953fd21a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Sat, 25 Jan 2025 17:58:54 +0000 Subject: [PATCH] Garmin: Add basic fit file viewer --- app/src/main/AndroidManifest.xml | 4 + .../activities/ActivitySummaryDetail.java | 9 +- .../activities/files/FileManagerAdapter.java | 9 + .../activities/fit/FitRecordAdapter.java | 154 ++++++++++++++++ .../activities/fit/FitViewerActivity.java | 167 ++++++++++++++++++ .../devices/garmin/fit/RecordData.java | 12 +- .../main/res/layout/activity_fit_viewer.xml | 16 ++ app/src/main/res/layout/item_fit_record.xml | 37 ++++ .../menu/activity_take_screenshot_menu.xml | 5 + app/src/main/res/menu/file_manager_file.xml | 3 + app/src/main/res/menu/menu_fit_viewer.xml | 11 ++ app/src/main/res/values/strings.xml | 4 + 12 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/fit/FitRecordAdapter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/fit/FitViewerActivity.java create mode 100644 app/src/main/res/layout/activity_fit_viewer.xml create mode 100644 app/src/main/res/layout/item_fit_record.xml create mode 100644 app/src/main/res/menu/menu_fit_viewer.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 403807852..967f6e8f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -189,6 +189,10 @@ android:name=".activities.files.FileManagerActivity" android:label="@string/activity_data_management_directory_content_title" android:parentActivityName=".activities.DataManagementActivity" /> + { final PopupMenu menu = new PopupMenu(mContext, holder.menu); menu.inflate(R.menu.file_manager_file); + menu.getMenu().findItem(R.id.file_manager_file_menu_view).setVisible(file.getPath().toLowerCase(Locale.ROOT).endsWith(".fit")); menu.setOnMenuItemClickListener(item -> { final int itemId = item.getItemId(); if (itemId == R.id.file_manager_file_menu_share) { @@ -104,6 +107,12 @@ public class FileManagerAdapter extends RecyclerView.Adapter. */ +package nodomain.freeyourgadget.gadgetbridge.activities.fit; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GlobalFITMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; + +public class FitRecordAdapter extends RecyclerView.Adapter { + protected static final Logger LOG = LoggerFactory.getLogger(FitRecordAdapter.class); + + private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US); + + private final List fitRecords; + private final List filteredRecords; + private final Set filter = new HashSet<>(); + private final Context mContext; + + public FitRecordAdapter(final Context context, final FitFile fitFile) { + mContext = context; + fitRecords = new ArrayList<>(fitFile.getRecords()); + filteredRecords = new ArrayList<>(fitRecords.size()); + refreshFilter(); + } + + @NonNull + @Override + public FitRecordViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final View view = LayoutInflater.from(mContext).inflate(R.layout.item_fit_record, parent, false); + return new FitRecordViewHolder(view); + } + + @Override + public void onBindViewHolder(final FitRecordViewHolder holder, int position) { + final RecordData record = filteredRecords.get(position); + + holder.title.setText(record.getGlobalFITMessage().name()); + if (record.getComputedTimestamp() != null) { + holder.description.setText(SDF.format(new Date(record.getComputedTimestamp() * 1000L))); + } else { + holder.description.setText(""); + } + + holder.itemView.setOnClickListener(v -> { + final String recordInfo = record.getFieldDataList().stream() + .sorted(Comparator.comparingInt(RecordData.FieldData::getNumber)) + .map(fieldData -> { + final String fieldName; + if (!StringUtils.isBlank(fieldData.getName())) { + fieldName = fieldData.getName(); + } else { + fieldName = "unknown_" + fieldData.getNumber() + fieldData; + } + Object o = fieldData.decode(); + final String fieldValueString; + if (o == null) { + fieldValueString = "null"; + } else if (o instanceof Object[]) { + fieldValueString = "[" + StringUtils.join((Object[]) o, ",") + "]"; + } else { + fieldValueString = o.toString(); + } + return fieldName + " = " + fieldValueString; + }).collect(Collectors.joining("\n")); + + new MaterialAlertDialogBuilder(mContext) + .setCancelable(true) + .setTitle(record.getGlobalFITMessage().name()) + .setMessage(recordInfo) + .setPositiveButton(R.string.ok, (dialog, which) -> { + }) + .setNeutralButton(android.R.string.copy, (dialog, which) -> { + final ClipboardManager clipboard = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + final ClipData clip = ClipData.newPlainText(record.getGlobalFITMessage().name(), recordInfo); + clipboard.setPrimaryClip(clip); + }) + .show(); + }); + } + + @Override + public int getItemCount() { + return filteredRecords.size(); + } + + public void updateFilter(final Set filter) { + this.filter.clear(); + this.filter.addAll(filter); + refreshFilter(); + } + + private void refreshFilter() { + filteredRecords.clear(); + if (filter.isEmpty()) { + filteredRecords.addAll(fitRecords); + } else { + filteredRecords.addAll(fitRecords.stream().filter(r -> filter.contains(r.getGlobalFITMessage())).collect(Collectors.toList())); + } + } + + public static class FitRecordViewHolder extends RecyclerView.ViewHolder { + final TextView title; + final TextView description; + + FitRecordViewHolder(final View itemView) { + super(itemView); + + title = itemView.findViewById(R.id.fit_record_title); + description = itemView.findViewById(R.id.fit_record_description); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/fit/FitViewerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/fit/FitViewerActivity.java new file mode 100644 index 000000000..53ac8d9d2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/fit/FitViewerActivity.java @@ -0,0 +1,167 @@ +/* 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 . */ +package nodomain.freeyourgadget.gadgetbridge.activities.fit; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.core.view.MenuProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GlobalFITMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class FitViewerActivity extends AbstractGBActivity implements MenuProvider { + private static final Logger LOG = LoggerFactory.getLogger(FitViewerActivity.class); + + public static final String EXTRA_PATH = "path"; + + private FitRecordAdapter fitRecordAdapter; + private FitFile fitFile; + private final Set filter = new HashSet<>(); + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_fit_viewer); + addMenuProvider(this); + + if (!getIntent().hasExtra(EXTRA_PATH)) { + GB.toast("Missing path", Toast.LENGTH_LONG, GB.ERROR); + finish(); + return; + } + + final RecyclerView fileListView = findViewById(R.id.fitRecordView); + fileListView.setLayoutManager(new LinearLayoutManager(this)); + + final File fitPath = new File(Objects.requireNonNull(getIntent().getStringExtra(EXTRA_PATH))); + if (!fitPath.isFile() || !fitPath.canRead()) { + GB.toast("Unable to read fit file", Toast.LENGTH_LONG, GB.ERROR); + finish(); + return; + } + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(fitPath.getName()); + } + + try { + fitFile = FitFile.parseIncoming(fitPath); + } catch (final IOException e) { + GB.toast("Failed to parse fit file", Toast.LENGTH_LONG, GB.ERROR); + LOG.error("Failed to parse fit file", e); + finish(); + return; + } + + fitRecordAdapter = new FitRecordAdapter(this, fitFile); + + fileListView.setAdapter(fitRecordAdapter); + } + + @Override + public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater menuInflater) { + menuInflater.inflate(R.menu.menu_fit_viewer, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull final MenuItem menuItem) { + final int itemId = menuItem.getItemId(); + if (itemId == R.id.fit_viewer_filter) { + final GlobalFITMessage[] globals = fitFile.getRecords().stream() + .map(RecordData::getGlobalFITMessage) + .distinct() + .sorted((a, b) -> { + if (a.name().startsWith("UNK_") && b.name().startsWith("UNK_")) { + return Integer.compare(a.getNumber(), b.getNumber()); + } else { + return a.name().compareToIgnoreCase(b.name()); + } + }) + .toArray(GlobalFITMessage[]::new); + + final boolean[] checked = new boolean[globals.length]; + for (int i = 0; i < globals.length; i++) { + if (filter.contains(globals[i])) { + checked[i] = true; + } + } + + final CharSequence[] mEntries = Arrays.stream(globals) + .map(GlobalFITMessage::name) + .toArray(CharSequence[]::new); + + new MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setTitle(R.string.filter_mode) + .setMultiChoiceItems(mEntries, checked, (dialog, which, isChecked) -> checked[which] = isChecked) + .setPositiveButton(R.string.ok, (dialog, which) -> { + filter.clear(); + for (int i = 0; i < globals.length; i++) { + if (checked[i]) { + filter.add(globals[i]); + } + } + fitRecordAdapter.updateFilter(filter); + fitRecordAdapter.notifyDataSetChanged(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + }) + .setNeutralButton(R.string.reset, (dialog, which) -> { + filter.clear(); + fitRecordAdapter.updateFilter(filter); + fitRecordAdapter.notifyDataSetChanged(); + }) + .show(); + return true; + } + return false; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java index f15599309..344531f03 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java @@ -79,6 +79,10 @@ public class RecordData { return recordDefinition; } + public List getFieldDataList() { + return fieldDataList; + } + public Long parseDataMessage(final GarminByteBufferReader garminByteBufferReader, final Long currentTimestamp) { garminByteBufferReader.setByteOrder(valueHolder.order()); computedTimestamp = currentTimestamp; @@ -196,7 +200,7 @@ public class RecordData { return tsb.build(); } - private class FieldData { + public class FieldData { private final FieldDefinition fieldDefinition; private final int position; private final int size; @@ -209,11 +213,11 @@ public class RecordData { this.baseSize = fieldDefinition.getBaseType().getSize(); } - private String getName() { + public String getName() { return fieldDefinition.getName(); } - private int getNumber() { + public int getNumber() { return fieldDefinition.getNumber(); } @@ -263,7 +267,7 @@ public class RecordData { } } - private Object decode() { + public Object decode() { goToPosition(); if (STRING.equals(fieldDefinition.getBaseType())) { final byte[] bytes = new byte[size]; diff --git a/app/src/main/res/layout/activity_fit_viewer.xml b/app/src/main/res/layout/activity_fit_viewer.xml new file mode 100644 index 000000000..a4ef722e3 --- /dev/null +++ b/app/src/main/res/layout/activity_fit_viewer.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/layout/item_fit_record.xml b/app/src/main/res/layout/item_fit_record.xml new file mode 100644 index 000000000..bfb9b9759 --- /dev/null +++ b/app/src/main/res/layout/item_fit_record.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/menu/activity_take_screenshot_menu.xml b/app/src/main/res/menu/activity_take_screenshot_menu.xml index d87cc95f7..3cec000de 100644 --- a/app/src/main/res/menu/activity_take_screenshot_menu.xml +++ b/app/src/main/res/menu/activity_take_screenshot_menu.xml @@ -56,6 +56,11 @@ android:title="@string/dev_tools" app:showAsAction="never"> + + + diff --git a/app/src/main/res/menu/menu_fit_viewer.xml b/app/src/main/res/menu/menu_fit_viewer.xml new file mode 100644 index 000000000..815ad6084 --- /dev/null +++ b/app/src/main/res/menu/menu_fit_viewer.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3504a51af..83f56e541 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1669,6 +1669,7 @@ Sharing file failed. Select all Share + View Share screenshot Screenshot taken Reset fetch date @@ -2371,6 +2372,7 @@ Duration Show GPS Track Share GPS Track + Inspect file Share Raw Summary Share Raw Details Share JSON Details @@ -3096,6 +3098,8 @@ Changelog More… OK + FIT File Viewer + Reset What\'s New Catima package name Install Catima