Workouts: Add function to set header photo

This commit is contained in:
Arjan Schrijver
2026-07-13 09:03:47 +02:00
parent ac33d1e715
commit b1adb29b96
14 changed files with 355 additions and 3 deletions
@@ -79,7 +79,7 @@ public class GBDaoGenerator {
outputDir.mkdirs();
}
final Schema schema = new Schema(135, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(136, MAIN_PACKAGE + ".entities");
final List<Entity> sampleProvidersToGenerate = new LinkedList<>();
@@ -1642,6 +1642,7 @@ public class GBDaoGenerator {
summary.addStringProperty("gpxTrack").codeBeforeGetter(OVERRIDE);
summary.addStringProperty("rawDetailsPath");
summary.addStringProperty("headerPhoto");
Property deviceId = summary.addLongProperty("deviceId").notNull().codeBeforeGetter(OVERRIDE).getProperty();
summary.addToOne(device, deviceId);
+1
View File
@@ -292,6 +292,7 @@ dependencies {
implementation libs.androidx.lifecycle.process
implementation libs.androidx.security.crypto
implementation libs.androidx.browser
implementation libs.androidx.exifinterface
implementation libs.material
implementation libs.flexbox
@@ -148,6 +148,7 @@ public class ActivityListingAdapter extends AbstractActivityListingAdapter<Activ
session.getIntensity(),
session.getEndTime().getTime() - session.getStartTime().getTime(),
false,
false,
null,
position % 2 == 1,
selected
@@ -92,6 +92,7 @@ public class ActivityListingDetail extends DialogFragment {
item.getIntensity(),
item.getEndTime().getTime() - item.getStartTime().getTime(),
false,
false,
null,
false,
false
@@ -20,6 +20,7 @@ import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.Gravity
@@ -115,7 +116,7 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
workoutEditor = WorkoutEditor(requireContext())
workoutEditor = WorkoutEditor(requireContext(), this)
arguments?.let {
workoutId = it.getLong(ARG_WORKOUT_ID, -1)
}
@@ -257,6 +258,14 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
)
view?.let {
// Header photo
if (summary.headerPhoto == null) {
binding.headerphoto.setImageDrawable(null)
} else {
binding.headerphoto.setImageURI(Uri.fromFile(File(summary.headerPhoto)))
}
// Activity icon
binding.itemImage.setImageResource(
ActivityKind.fromCode(summary.activityKind).icon
)
@@ -599,6 +608,32 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
true
}
R.id.activity_summary_detail_action_add_photo -> {
currentWorkout?.let {
workoutEditor.setHeaderPhoto(it, object : WorkoutEditor.Callback {
override fun onWorkoutUpdated() {
notifyWorkoutChanged()
// Reload only the workout header
updateWorkoutHeader(it.summary)
}
})
}
true
}
R.id.activity_summary_detail_action_remove_photo -> {
currentWorkout?.let {
workoutEditor.removeHeaderPhoto(it, object : WorkoutEditor.Callback {
override fun onWorkoutUpdated() {
notifyWorkoutChanged()
// Reload only the workout header
updateWorkoutHeader(it.summary)
}
})
}
true
}
R.id.activity_summary_detail_action_edit_gps -> {
currentWorkout?.let {
workoutEditor.editGpsTrack(it, object : WorkoutEditor.Callback {
@@ -649,6 +684,10 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
devToolsMenu?.isVisible = devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems()
}
val overflowMenu2 = menu.findItem(R.id.activity_detail_overflowMenu2)?.subMenu
overflowMenu2?.findItem(R.id.activity_summary_detail_action_add_photo)?.isVisible = workout.summary.headerPhoto == null
overflowMenu2?.findItem(R.id.activity_summary_detail_action_remove_photo)?.isVisible = workout.summary.headerPhoto != null
// Endurain accepts FIT (built from the summary alone if needed), so it is offered
// for any workout. Wanderer only supports GPX uploads, so it requires a GPS track.
val endurainVm: EndurainSetupViewModel by viewModels()
@@ -1,16 +1,45 @@
/* Copyright (C) 2020-2026 José Rebelo, Arjan Schrijver
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.workouts
import android.content.Context
import android.content.DialogInterface
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.text.InputType
import android.view.ViewGroup
import android.webkit.MimeTypeMap
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils
import nodomain.freeyourgadget.gadgetbridge.util.GB
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileFilter
@@ -18,13 +47,20 @@ import java.io.IOException
import java.util.Collections
import java.util.Locale
class WorkoutEditor(private val context: Context) {
class WorkoutEditor(private val context: Context, resultCaller: ActivityResultCaller) {
var filesGpxList: MutableList<String> = ArrayList()
var selectedGpxIndex: Int = 0
var selectedGpxFile: String? = null
var exportPathRoot: File? = null // original gpx file location, could still contain old gpx files
var exportPathGpx: File? = null // new gpx file location
private val pickHeaderPhotoLauncher: ActivityResultLauncher<PickVisualMediaRequest> =
resultCaller.registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> onHeaderPhotoPicked(uri) }
private var pendingHeaderPhotoWorkout: Workout? = null
private var pendingHeaderPhotoCallback: Callback? = null
fun editWorkoutName(workout: Workout, callback: Callback) {
val input = EditText(context).apply {
inputType = InputType.TYPE_CLASS_TEXT
@@ -139,11 +175,208 @@ class WorkoutEditor(private val context: Context) {
dialog.show()
}
fun setHeaderPhoto(workout: Workout, callback: Callback) {
pendingHeaderPhotoWorkout = workout
pendingHeaderPhotoCallback = callback
pickHeaderPhotoLauncher.launch(
PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
.build()
)
}
fun removeHeaderPhoto(workout: Workout, callback: Callback) {
val photoFile = File(workout.summary.headerPhoto)
if (photoFile.exists())
photoFile.delete()
workout.summary.headerPhoto = null
workout.summary.update()
callback.onWorkoutUpdated()
}
private fun onHeaderPhotoPicked(uri: Uri?) {
val workout = pendingHeaderPhotoWorkout
val callback = pendingHeaderPhotoCallback
pendingHeaderPhotoWorkout = null
pendingHeaderPhotoCallback = null
if (uri == null || workout == null || callback == null) {
// user backed out of the picker, nothing to do
return
}
val savedPath = copyHeaderPhotoToAppStorage(uri)
if (savedPath != null) {
workout.summary.headerPhoto = savedPath
workout.summary.update()
callback.onWorkoutUpdated()
} else {
GB.toast(
context,
"Importing photo failed",
Toast.LENGTH_LONG,
GB.ERROR
)
}
}
private fun copyHeaderPhotoToAppStorage(uri: Uri): String? {
return try {
val exportPathPhotos = File(getPath(), "workout_photos")
if (!exportPathPhotos.exists() && !exportPathPhotos.mkdirs()) {
LOG.error("Failed to create photos directory: {}", exportPathPhotos)
return null
}
val mimeType = context.contentResolver.getType(uri)
val extension = mimeType
?.let { mime -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) }
?: "jpg"
val destFile = File(exportPathPhotos, "workout_header_${System.currentTimeMillis()}.$extension")
LOG.debug("Photo destination path: {}", destFile)
// Read photo dimensions
val boundsStream = context.contentResolver.openInputStream(uri) ?: return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
boundsStream.use { input -> BitmapFactory.decodeStream(input, null, bounds) }
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
LOG.debug("Photo not decodable")
return null
}
// Determine rotation
val orientation = readExifOrientation(uri)
val orientationSwapsDimensions = orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
orientation == ExifInterface.ORIENTATION_ROTATE_270 ||
orientation == ExifInterface.ORIENTATION_TRANSPOSE ||
orientation == ExifInterface.ORIENTATION_TRANSVERSE
val needsRotation = orientation != ExifInterface.ORIENTATION_NORMAL &&
orientation != ExifInterface.ORIENTATION_UNDEFINED
LOG.debug("Photo needs rotation: {}", needsRotation)
// Determine whether resize is needed
val effectiveWidth = if (orientationSwapsDimensions) bounds.outHeight else bounds.outWidth
val needsResize = effectiveWidth > MAX_HEADER_PHOTO_WIDTH_PX
LOG.debug("Photo needs resize: {}", needsResize)
if (!needsRotation && !needsResize) {
val copied = context.contentResolver.openInputStream(uri)?.use { input ->
destFile.outputStream().use { output -> input.copyTo(output) }
true
} ?: false
if (!copied) {
LOG.debug("Photo could not be copied")
return null
}
} else {
val processed = decodeRotateAndResize(uri, bounds.outWidth, orientation, needsResize) ?: return null
val compressFormat = when {
mimeType?.contains("png") == true -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG
}
val written = destFile.outputStream().use { output ->
processed.compress(compressFormat, 90, output)
}
processed.recycle()
if (!written) {
LOG.debug("Photo could not be rotated and resized")
return null
}
}
destFile.path
} catch (e: IOException) {
LOG.error("Error copying header photo", e)
null
}
}
private fun readExifOrientation(uri: Uri): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
)
} ?: ExifInterface.ORIENTATION_UNDEFINED
} catch (_: IOException) {
// If no EXIF info found
ExifInterface.ORIENTATION_UNDEFINED
}
}
private fun matrixForExifOrientation(orientation: Int): Matrix {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(-90f)
else -> {} // ORIENTATION_NORMAL / ORIENTATION_UNDEFINED
}
return matrix
}
private fun decodeRotateAndResize(
uri: Uri,
originalWidth: Int,
orientation: Int,
needsResize: Boolean
): Bitmap? {
val sampleSize = if (needsResize) calculateInSampleSize(originalWidth, MAX_HEADER_PHOTO_WIDTH_PX) else 1
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
val sampledBitmap = context.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input, null, decodeOptions)
} ?: return null
val orientationMatrix = matrixForExifOrientation(orientation)
val rotatedBitmap = if (orientationMatrix.isIdentity) {
sampledBitmap
} else {
Bitmap.createBitmap(
sampledBitmap, 0, 0, sampledBitmap.width, sampledBitmap.height, orientationMatrix, true
).also {
if (it !== sampledBitmap) sampledBitmap.recycle()
}
}
if (rotatedBitmap.width <= MAX_HEADER_PHOTO_WIDTH_PX) {
return rotatedBitmap
}
val scaledHeight =
(rotatedBitmap.height.toFloat() / rotatedBitmap.width * MAX_HEADER_PHOTO_WIDTH_PX).toInt()
val resized = rotatedBitmap.scale(MAX_HEADER_PHOTO_WIDTH_PX, scaledHeight)
if (resized !== rotatedBitmap) {
rotatedBitmap.recycle()
}
return resized
}
private fun calculateInSampleSize(originalWidth: Int, targetWidth: Int): Int {
var sampleSize = 1
var halfWidth = originalWidth / 2
while (halfWidth / sampleSize >= targetWidth) {
sampleSize *= 2
}
return sampleSize
}
interface Callback {
fun onWorkoutUpdated()
}
companion object {
private val LOG = LoggerFactory.getLogger(WorkoutEditor::class.java)
private const val MAX_HEADER_PHOTO_WIDTH_PX = 1000
}
}
@@ -105,6 +105,7 @@ class WorkoutSummariesAdapter(
-1f,
summary.endTime.time - summary.startTime.time,
hasGps,
workout.summary.headerPhoto != null,
summary.startTime,
position % 2 == 1,
selected
@@ -0,0 +1,39 @@
/* Copyright (C) 2026 Arjan Schrijver
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.database.schema;
import android.database.sqlite.SQLiteDatabase;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
public class GadgetbridgeUpdate_136 implements DBUpdateScript {
@Override
public void upgradeSchema(final SQLiteDatabase db) {
if (!DBHelper.existsColumn(BaseActivitySummaryDao.TABLENAME, BaseActivitySummaryDao.Properties.HeaderPhoto.columnName, db)) {
final String statement = "ALTER TABLE " + BaseActivitySummaryDao.TABLENAME + " ADD COLUMN \""
+ BaseActivitySummaryDao.Properties.HeaderPhoto.columnName + "\" TEXT";
db.execSQL(statement);
}
}
@Override
public void downgradeSchema(final SQLiteDatabase db) {
}
}
@@ -42,6 +42,7 @@ public class ActivityListItem {
private final RelativeLayout parentLayout;
private final ImageView activityIcon;
private final ImageView gpsIcon;
private final ImageView photoIcon;
private final int backgroundColor;
private final int alternateColor;
@@ -71,6 +72,7 @@ public class ActivityListItem {
this.activityIcon = itemView.findViewById(R.id.line_layout_activity_icon);
this.gpsIcon = itemView.findViewById(R.id.line_layout_gps_icon);
this.photoIcon = itemView.findViewById(R.id.line_layout_photo_icon);
this.backgroundColor = 0;
this.alternateColor = getThemedColor(itemView.getContext(), R.attr.alternate_row_background);
@@ -87,6 +89,7 @@ public class ActivityListItem {
final float intensity,
final long duration,
final boolean hasGps,
final boolean hasHeaderPhoto,
@Nullable final Date date,
final boolean zebraStripe,
final boolean selected) {
@@ -150,6 +153,12 @@ public class ActivityListItem {
gpsIcon.setVisibility(View.GONE);
}
if (hasHeaderPhoto) {
photoIcon.setVisibility(View.VISIBLE);
} else {
photoIcon.setVisibility(View.GONE);
}
activityIcon.setImageResource(activityKind.getIcon());
if (parentLayout != null) {
@@ -137,6 +137,13 @@
android:gravity="end"
android:orientation="horizontal">
<ImageView
android:id="@+id/line_layout_photo_icon"
android:layout_width="20dp"
android:layout_marginStart="2dp"
android:layout_height="19dp"
android:contentDescription="@string/candidate_item_device_image"
app:srcCompat="@android:drawable/ic_menu_camera" />
<ImageView
android:id="@+id/line_layout_gps_icon"
android:layout_width="20dp"
@@ -17,6 +17,11 @@
android:orientation="vertical">
<!-- header -->
<ImageView
android:id="@+id/headerphoto"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -16,6 +16,17 @@
android:title="@string/activity_summary_edit_name_title"
app:showAsAction="never" />
<item
android:id="@+id/activity_summary_detail_action_add_photo"
android:icon="@drawable/ic_camera_remote"
android:title="@string/activity_summary_set_header_photo_title"
app:showAsAction="never" />
<item
android:id="@+id/activity_summary_detail_action_remove_photo"
android:icon="@drawable/ic_camera_remote"
android:title="@string/activity_summary_remove_header_photo_title"
app:showAsAction="never" />
<item
android:id="@+id/activity_summary_detail_action_edit_gps"
android:icon="@drawable/ic_gps_edit"
+2
View File
@@ -5195,4 +5195,6 @@
<string name="qs_tile_label_8">Gadgetbridge 8</string>
<string name="qs_tile_label_9">Gadgetbridge 9</string>
<string name="qs_tile_label_10">Gadgetbridge 10</string>
<string name="activity_summary_set_header_photo_title">Set header photo</string>
<string name="activity_summary_remove_header_photo_title">Remove header photo</string>
</resources>
+2
View File
@@ -52,6 +52,7 @@ slf4j = "2.0.18"
solarpositioning = "0.1.10"
security-crypto = "1.1.0"
browser = "1.10.0"
exifinterface = "1.3.6"
[libraries]
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
@@ -116,6 +117,7 @@ slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
[bundles]
android-emojify = [