Garmin: Add basic fit file viewer

This commit is contained in:
José Rebelo 2025-01-25 17:58:54 +00:00
parent 0fe05fe9b8
commit b49fe1730c
12 changed files with 426 additions and 5 deletions

View File

@ -189,6 +189,10 @@
android:name=".activities.files.FileManagerActivity"
android:label="@string/activity_data_management_directory_content_title"
android:parentActivityName=".activities.DataManagementActivity" />
<activity
android:name=".activities.fit.FitViewerActivity"
android:exported="true"
android:label="@string/fit_file_viewer" />
<activity
android:name=".activities.BackupRestoreProgressActivity"
android:label="@string/activity_db_management_backup_restore_label"

View File

@ -30,7 +30,6 @@ import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.text.InputType;
import android.text.format.DateUtils;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.Menu;
@ -75,11 +74,13 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
@ -448,6 +449,11 @@ public class ActivitySummaryDetail extends AbstractGBActivity {
} else if (itemId == R.id.activity_action_share_gpx) {
shareGpxTrack(ActivitySummaryDetail.this);
return true;
} else if (itemId == R.id.activity_action_dev_inspect_file) {
final Intent inspectFileIntent = new Intent(ActivitySummaryDetail.this, FitViewerActivity.class);
inspectFileIntent.putExtra(FitViewerActivity.EXTRA_PATH, currentItem.getRawDetailsPath());
startActivity(inspectFileIntent);
return true;
} else if (itemId == R.id.activity_action_dev_share_raw_summary) {
shareRawSummary(ActivitySummaryDetail.this, currentItem);
return true;
@ -665,6 +671,7 @@ public class ActivitySummaryDetail extends AbstractGBActivity {
if (overflowMenu != null) {
overflowMenu.findItem(R.id.activity_action_show_gpx).setVisible(hasGpx);
overflowMenu.findItem(R.id.activity_action_share_gpx).setVisible(hasGpx);
overflowMenu.findItem(R.id.activity_action_dev_inspect_file).setVisible(hasRawDetails && currentItem.getRawDetailsPath().toLowerCase(Locale.ROOT).endsWith(".fit"));
overflowMenu.findItem(R.id.activity_action_dev_share_raw_summary).setVisible(hasRawSummary);
overflowMenu.findItem(R.id.activity_action_dev_share_raw_details).setVisible(hasRawDetails);
final MenuItem devToolsMenu = overflowMenu.findItem(R.id.activity_action_dev_tools);

View File

@ -40,8 +40,10 @@ import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@ -94,6 +96,7 @@ public class FileManagerAdapter extends RecyclerView.Adapter<FileManagerAdapter.
holder.menu.setOnClickListener(view -> {
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<FileManagerAdapter.
}
return true;
}
if (itemId == R.id.file_manager_file_menu_view) {
final Intent inspectFileIntent = new Intent(mContext, FitViewerActivity.class);
inspectFileIntent.putExtra(FitViewerActivity.EXTRA_PATH, file.getPath());
mContext.startActivity(inspectFileIntent);
return true;
}
return false;
});

View File

@ -0,0 +1,154 @@
/* 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.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<FitRecordAdapter.FitRecordViewHolder> {
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<RecordData> fitRecords;
private final List<RecordData> filteredRecords;
private final Set<GlobalFITMessage> 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<GlobalFITMessage> 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);
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>. */
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<GlobalFITMessage> 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);
}
}

View File

@ -79,6 +79,10 @@ public class RecordData {
return recordDefinition;
}
public List<FieldData> 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];

View File

@ -0,0 +1,16 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.fit.FitViewerActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fitRecordView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:divider="@null"
android:scrollbarSize="5dp"
android:scrollbars="vertical" />
</RelativeLayout>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/fit_record_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollHorizontally="false"
android:text="-"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/fit_record_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text=""
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
</RelativeLayout>

View File

@ -56,6 +56,11 @@
android:title="@string/dev_tools"
app:showAsAction="never">
<menu>
<item
android:id="@+id/activity_action_dev_inspect_file"
android:title="@string/activity_detail_inspect_file"
app:showAsAction="never" />
<item
android:id="@+id/activity_action_dev_share_raw_summary"
android:title="@string/activity_detail_share_raw_summary"

View File

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/file_manager_file_menu_view"
android:title="@string/view_file" />
<item
android:id="@+id/file_manager_file_menu_share"
android:title="@string/share" />

View File

@ -0,0 +1,11 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity">
<item
android:id="@+id/fit_viewer_filter"
android:icon="@drawable/ic_filter_alt"
android:title="@string/filter_mode"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="always" />
</menu>

View File

@ -1669,6 +1669,7 @@
<string name="activity_error_share_failed">Sharing file failed.</string>
<string name="select_all">Select all</string>
<string name="share">Share</string>
<string name="view_file">View</string>
<string name="share_screenshot">Share screenshot</string>
<string name="screenshot_taken">Screenshot taken</string>
<string name="reset_index">Reset fetch date</string>
@ -2371,6 +2372,7 @@
<string name="activity_detail_duration_label">Duration</string>
<string name="activity_detail_show_gps_label">Show GPS Track</string>
<string name="activity_detail_share_gps_label">Share GPS Track</string>
<string name="activity_detail_inspect_file">Inspect file</string>
<string name="activity_detail_share_raw_summary">Share Raw Summary</string>
<string name="activity_detail_share_raw_details">Share Raw Details</string>
<string name="activity_detail_share_json_details">Share JSON Details</string>
@ -3096,6 +3098,8 @@
<string name="changelog_full_title">Changelog</string>
<string name="changelog_show_full">More…</string>
<string name="changelog_ok_button">OK</string>
<string name="fit_file_viewer">FIT File Viewer</string>
<string name="reset">Reset</string>
<string name="changelog_title">What\'s New</string>
<string name="loyalty_cards_catima_package">Catima package name</string>
<string name="loyalty_cards_install">Install Catima</string>