diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/FitDecoder.kt b/app/src/main/java/net/duhowpi/ftmsbridge/FitDecoder.kt new file mode 100644 index 0000000..14852a7 --- /dev/null +++ b/app/src/main/java/net/duhowpi/ftmsbridge/FitDecoder.kt @@ -0,0 +1,235 @@ +package net.duhowpi.ftmsbridge + +import net.duhowpi.ftmsbridge.data.WorkoutSample +import net.duhowpi.ftmsbridge.data.WorkoutSession +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +/** + * Minimal FIT activity file decoder — the inverse of [FitEncoder]. + * Reads session (18), record (20) and device_info (23) messages and maps them + * back to Room entities, so exported workouts can be re-imported (backup/restore). + */ +internal object FitDecoder { + + /** Seconds between Unix epoch (1970-01-01) and FIT epoch (1989-01-01 UTC). */ + private const val FIT_EPOCH_OFFSET = 631065600L + + /** Safety cap for a single zip entry / fit file (a real workout is a few hundred KB). */ + private const val MAX_FIT_BYTES = 20 * 1024 * 1024 + + class DecodedWorkout(val session: WorkoutSession, val samples: List) + + private class FieldDef(val num: Int, val size: Int, val baseType: Int) + private class MsgDef(val global: Int, val bigEndian: Boolean, val fields: List, val devBytes: Int) + + /** Byte size per FIT base-type number (0 = unsupported here). */ + private val BASE_SIZE = intArrayOf(1, 1, 1, 2, 2, 4, 4, 0, 0, 0, 1, 2, 4, 1, 0, 0, 0) + + fun isZip(bytes: ByteArray): Boolean = + bytes.size > 4 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte() + + /** + * Extracts all root-level `.fit` entries from a zip archive (subfolders ignored). + */ + fun unzipFits(bytes: ByteArray): List { + val result = mutableListOf() + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory && !entry.name.contains('/') && + entry.name.endsWith(".fit", ignoreCase = true) + ) { + val data = zip.readBytes() + if (data.size <= MAX_FIT_BYTES) result.add(data) + } + entry = zip.nextEntry + } + } + return result + } + + /** + * Decodes a FIT activity file. + * @throws IllegalArgumentException if the data is not a parseable FIT file. + */ + fun decode(bytes: ByteArray): DecodedWorkout { + require(bytes.size in 17..MAX_FIT_BYTES) { "Bad file size" } + val headerSize = bytes[0].toInt() and 0xFF + require(headerSize >= 12 && bytes.size > headerSize + 2) { "Bad FIT header" } + require(String(bytes, 8, 4, Charsets.US_ASCII) == ".FIT") { "Missing .FIT magic" } + val dataSize = readUInt(bytes, 4, 4, bigEndian = false) + val dataEnd = (headerSize + dataSize).coerceAtMost((bytes.size - 2).toLong()).toInt() + + val defs = HashMap() + var sessionMsg: Map? = null + var deviceName = "" + val records = ArrayList>() + var lastTimestamp = 0L + + var pos = headerSize + while (pos < dataEnd) { + val hdr = bytes[pos++].toInt() and 0xFF + if (hdr and 0xC0 == 0x40) { + // ── Definition message ── + val hasDev = hdr and 0x20 != 0 + val local = hdr and 0xF + pos++ // reserved + val bigEndian = bytes[pos++].toInt() != 0 + val global = readUInt(bytes, pos, 2, bigEndian).toInt(); pos += 2 + val numFields = bytes[pos++].toInt() and 0xFF + val fields = ArrayList(numFields) + repeat(numFields) { + fields.add(FieldDef( + bytes[pos].toInt() and 0xFF, + bytes[pos + 1].toInt() and 0xFF, + bytes[pos + 2].toInt() and 0xFF + )) + pos += 3 + } + var devBytes = 0 + if (hasDev) { + val numDev = bytes[pos++].toInt() and 0xFF + repeat(numDev) { + devBytes += bytes[pos + 1].toInt() and 0xFF + pos += 3 + } + } + defs[local] = MsgDef(global, bigEndian, fields, devBytes) + } else { + // ── Data message (normal or compressed-timestamp) ── + val compressed = hdr and 0x80 != 0 + val local = if (compressed) (hdr ushr 5) and 0x3 else hdr and 0xF + val def = defs[local] ?: throw IllegalArgumentException("Data before definition") + val values = HashMap() + val strings = HashMap() + for (f in def.fields) { + decodeField(bytes, pos, f, def.bigEndian, values, strings) + pos += f.size + } + pos += def.devBytes + if (compressed) { + val offset = (hdr and 0x1F).toLong() + lastTimestamp += (offset - (lastTimestamp and 0x1FL)) and 0x1FL + values[253] = lastTimestamp + } else { + values[253]?.let { lastTimestamp = it } + } + when (def.global) { + 18 -> sessionMsg = values + 20 -> records.add(values) + 23 -> strings[19]?.takeIf { it.isNotBlank() }?.let { deviceName = it } + } + } + } + + return buildWorkout(sessionMsg, records, deviceName) + } + + private fun buildWorkout( + s: Map?, + records: List>, + deviceName: String + ): DecodedWorkout { + val startTs = s?.get(2) ?: records.firstOrNull()?.get(253) + ?: throw IllegalArgumentException("No session or record data") + val endTs = s?.get(253) ?: records.lastOrNull()?.get(253) + val startMs = fitToUnixMs(startTs) + + val machineType = when (s?.get(6)?.toInt()) { + FitSubSport.TREADMILL.value -> "TREADMILL" + FitSubSport.INDOOR_CYCLING.value -> "INDOOR_BIKE" + FitSubSport.ELLIPTICAL.value -> "CROSS_TRAINER" + FitSubSport.STAIR_CLIMBING.value -> "STAIR_CLIMBER" + else -> "" + } + + val session = WorkoutSession( + startTimeMs = startMs, + endTimeMs = endTs?.let { fitToUnixMs(it) }, + machineType = machineType, + deviceName = deviceName, + totalDistanceM = ((s?.get(9) ?: records.lastOrNull()?.get(5) ?: 0L) / 100L).toInt(), + totalEnergyKcal = (s?.get(11) ?: 0L).toInt(), + totalElapsedTimeSec = (s?.get(7)?.div(1000L) ?: endTs?.minus(startTs) ?: 0L).toInt(), + avgSpeedKmh = mmSToKmh(s?.get(14) ?: 0L), + avgCadenceRpm = (s?.get(18) ?: 0L).toDouble(), + avgPowerW = (s?.get(20) ?: 0L).toInt(), + avgHeartRateBpm = (s?.get(16) ?: 0L).toInt(), + maxSpeedKmh = mmSToKmh(s?.get(15) ?: 0L), + maxHeartRateBpm = (s?.get(17) ?: 0L).toInt(), + maxPowerW = (s?.get(21) ?: 0L).toInt() + ) + + val samples = records.mapNotNull { r -> + val ts = r[253] ?: return@mapNotNull null + val tsMs = fitToUnixMs(ts) + WorkoutSample( + sessionId = 0, + timestampMs = tsMs, + elapsedTimeSec = ((tsMs - startMs) / 1000L).toInt(), + speedKmh = mmSToKmh(r[6] ?: 0L), + cadenceRpm = (r[4] ?: 0L).toDouble(), + instantaneousPowerW = (r[7] ?: 0L).toInt(), + totalDistanceM = ((r[5] ?: 0L) / 100L).toInt(), + heartRateBpm = (r[3] ?: 0L).toInt(), + inclinationPercent = (r[9] ?: 0L) / 100.0, + resistanceLevel = (r[10] ?: 0L).toInt(), + totalEnergyKcal = (r[33] ?: 0L).toInt() + ) + } + + return DecodedWorkout(session, samples) + } + + private fun fitToUnixMs(ts: Long): Long = (ts + FIT_EPOCH_OFFSET) * 1000L + + private fun mmSToKmh(mmS: Long): Double = mmS / 1000.0 * 3.6 + + private fun readUInt(bytes: ByteArray, pos: Int, len: Int, bigEndian: Boolean): Long { + var v = 0L + for (i in 0 until len) { + val b = (bytes[pos + i].toInt() and 0xFF).toLong() + v = v or (b shl (8 * if (bigEndian) len - 1 - i else i)) + } + return v + } + + private fun decodeField( + bytes: ByteArray, + pos: Int, + f: FieldDef, + bigEndian: Boolean, + values: MutableMap, + strings: MutableMap + ) { + val bt = f.baseType and 0x1F + if (bt == 7) { // string: null-terminated + var end = pos + while (end < pos + f.size && bytes[end].toInt() != 0) end++ + strings[f.num] = String(bytes, pos, end - pos, Charsets.UTF_8) + return + } + val expected = BASE_SIZE.getOrElse(bt) { 0 } + // Skip floats, 64-bit types and arrays — not used by our profile subset + if (expected == 0 || f.size != expected) return + var raw = readUInt(bytes, pos, f.size, bigEndian) + val invalid = when (bt) { + 0, 2, 13 -> 0xFFL + 1 -> 0x7FL + 3 -> 0x7FFFL + 4 -> 0xFFFFL + 5 -> 0x7FFFFFFFL + 6 -> 0xFFFFFFFFL + 10, 11, 12 -> 0L + else -> return + } + if (raw == invalid) return + when (bt) { // sign-extend signed types + 1 -> raw = raw.toByte().toLong() + 3 -> raw = raw.toShort().toLong() + 5 -> raw = raw.toInt().toLong() + } + values[f.num] = raw + } +} diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/WorkoutHistoryActivity.kt b/app/src/main/java/net/duhowpi/ftmsbridge/WorkoutHistoryActivity.kt index 7416870..c9bf868 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/WorkoutHistoryActivity.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/WorkoutHistoryActivity.kt @@ -1,13 +1,16 @@ package net.duhowpi.ftmsbridge import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider @@ -21,9 +24,12 @@ import kotlinx.coroutines.withContext import net.duhowpi.ftmsbridge.data.AppDatabase import net.duhowpi.ftmsbridge.data.WorkoutSession import net.duhowpi.ftmsbridge.databinding.ActivityWorkoutHistoryBinding +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream class WorkoutHistoryActivity : AppCompatActivity() { @@ -33,6 +39,11 @@ class WorkoutHistoryActivity : AppCompatActivity() { private val displayDateFormat = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault()) + private val importLauncher = + registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) importFromUris(uris) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityWorkoutHistoryBinding.inflate(layoutInflater) @@ -59,12 +70,95 @@ class WorkoutHistoryActivity : AppCompatActivity() { loadSessions() } + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_workout_history, menu) + return true + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true + return when (item.itemId) { + android.R.id.home -> { finish(); true } + R.id.action_export_all -> { exportAllAsZip(); true } + R.id.action_import -> { + // .fit has no registered MIME type, so accept any file and validate content + importLauncher.launch(arrayOf("*/*")) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun exportAllAsZip() { + lifecycleScope.launch { + val zipFile = withContext(Dispatchers.IO) { + val sessions = db.sessionDao().getAll() + if (sessions.isEmpty()) return@withContext null + val dir = getExternalFilesDir(null)?.resolve("exports") ?: filesDir.resolve("exports") + dir.mkdirs() + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val file = File(dir, "ftmsbridge_backup_${stamp}.zip") + ZipOutputStream(file.outputStream().buffered()).use { zip -> + for (session in sessions) { + val samples = db.sampleDao().getAllBySession(session.id) + val name = WorkoutExporter.exportFileName(session, "fit") + .removeSuffix(".fit") + "_${session.id}.fit" + zip.putNextEntry(ZipEntry(name)) + zip.write(WorkoutExporter.toFit(session, samples)) + zip.closeEntry() + } + } + file + } + if (zipFile == null) { + Toast.makeText(this@WorkoutHistoryActivity, R.string.no_sessions, Toast.LENGTH_SHORT).show() + return@launch + } + val uri = FileProvider.getUriForFile(this@WorkoutHistoryActivity, "${packageName}.fileprovider", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(intent, getString(R.string.export_all))) + } + } + + private fun importFromUris(uris: List) { + lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + val fitFiles = mutableListOf() + for (uri in uris) { + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: continue + try { + if (FitDecoder.isZip(bytes)) fitFiles.addAll(FitDecoder.unzipFits(bytes)) + else fitFiles.add(bytes) + } catch (e: Exception) { + // corrupt zip: skip this file, keep processing the rest + } + } + if (fitFiles.isEmpty()) return@withContext null + val existing = db.sessionDao().getAll().map { it.startTimeMs }.toHashSet() + var imported = 0 + var skipped = 0 + for (fit in fitFiles) { + val decoded = runCatching { FitDecoder.decode(fit) }.getOrNull() + if (decoded == null || !existing.add(decoded.session.startTimeMs)) { + skipped++ + continue + } + val newId = db.sessionDao().insert(decoded.session) + db.sampleDao().insertAll(decoded.samples.map { it.copy(sessionId = newId) }) + imported++ + } + Pair(imported, skipped) + } + val message = when { + result == null || result.first + result.second == 0 -> getString(R.string.import_none) + else -> getString(R.string.import_result, result.first, result.second) + } + Toast.makeText(this@WorkoutHistoryActivity, message, Toast.LENGTH_LONG).show() + if (result != null && result.first > 0) loadSessions() } - return super.onOptionsItemSelected(item) } private fun loadSessions() { diff --git a/app/src/main/res/menu/menu_workout_history.xml b/app/src/main/res/menu/menu_workout_history.xml new file mode 100644 index 0000000..04402b5 --- /dev/null +++ b/app/src/main/res/menu/menu_workout_history.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index bfc9284..7df4e3b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -30,6 +30,10 @@ FIT CSV Format d\'exportació + Exporta-ho tot (ZIP) + Importa entrenaments + %1$d entrenaments importats, %2$d omesos + No s\'han trobat entrenaments al fitxer Durada Vel. mitjana FC mitjana diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f6b7431..b50a5af 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -30,6 +30,10 @@ FIT CSV Formato de exportación + Exportar todo (ZIP) + Importar entrenamientos + %1$d entrenamientos importados, %2$d omitidos + No se encontraron entrenamientos en el archivo Duración Vel. media FC media diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 794ffd8..8c4c343 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,10 @@ FIT CSV Export format + Export all (ZIP) + Import workouts + Imported %1$d workouts, %2$d skipped + No workouts found in file Duration Avg speed Avg HR diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt index 946b7eb..e689d9c 100644 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -6,3 +6,4 @@ - Workout card no longer jumps when switching between lap and chart views - Device scan list now uses all the available screen space and is the only scrolling area (no more double scroll) - Live chart shows a marker line at every completed kilometre +- New menu in Workout History to export all workouts as a ZIP of FIT files and import them back (backup/restore)