/** * @file DebugSessionLogger.kt * @brief Per-frame diagnostic trace written to a text file for the duration of a recording. */ package com.example.jnicpp.bowling import android.content.ContentValues import android.content.Context import android.os.Build import android.os.Environment import android.provider.MediaStore import com.google.mlkit.vision.pose.PoseLandmark import java.io.BufferedWriter import java.io.File import java.io.FileOutputStream import java.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Locale import kotlin.math.sqrt /** * @brief Writes one line per analyzed frame -- landmark confidence, * position, and derived torso scale -- to a plain-text file in the * public Downloads/bowling folder, for the duration of a single * recording. * * Exists purely as a step-counting troubleshooting aid: it's a much richer, * un-throttled trace than the 1/sec Logcat line in * [BowlingCameraActivity.onPoseResult], and lands somewhere retrievable * without needing adb or the Logcat panel -- the file shows up like any * other downloaded file, so it can be opened, shared, or copied off the * device by whatever means is convenient. * * One instance is meant to be reused across the Activity's lifetime; call * [start] when a recording begins and [stop] when it ends. Calling [log] * while not started is a harmless no-op. */ class DebugSessionLogger(private val appContext: Context) { private var writer: BufferedWriter? = null private var lastLoggedMs: Long? = null /** * @brief Opens a new timestamped file in Downloads/bowling and writes a header line. * * Uses `MediaStore.Downloads` on API 29+ (scoped storage, no extra * permission needed) and a direct file write into the public Downloads * directory below that, mirroring [CameraXController.startRecording]'s * own API-level branching for saving the video file. */ fun start() { stop() val fileName = "bowling_debug_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(java.util.Date())}.txt" val outputStream = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, fileName) put(MediaStore.Downloads.MIME_TYPE, "text/plain") put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/bowling") } val uri = appContext.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) uri?.let { appContext.contentResolver.openOutputStream(it) } } else { val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "bowling") dir.mkdirs() FileOutputStream(File(dir, fileName)) } } catch (_: Exception) { null } writer = outputStream?.let { BufferedWriter(OutputStreamWriter(it)) } lastLoggedMs = null writer?.let { it.write( "timestampMs dtMs ankleL(y,lik) ankleR(y,lik) hipL(y,lik) hipR(y,lik) shoulderL(y,lik) shoulderR(y,lik) " + "wristL(y,lik) wristR(y,lik) torsoScalePx stepCount", ) it.newLine() it.flush() } } /** * @brief Appends one frame's diagnostic data as a line, if a session is currently open. * @param landmarks EMA-smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant. * @param timestampMs Wall-clock time this frame was analyzed, in milliseconds. * @param stepCount Current cumulative step count at the time of this frame. */ fun log(landmarks: Map, timestampMs: Long, stepCount: Int) { val out = writer ?: return val ankleL = landmarks[PoseLandmark.LEFT_ANKLE] val ankleR = landmarks[PoseLandmark.RIGHT_ANKLE] val hipL = landmarks[PoseLandmark.LEFT_HIP] val hipR = landmarks[PoseLandmark.RIGHT_HIP] val shoulderL = landmarks[PoseLandmark.LEFT_SHOULDER] val shoulderR = landmarks[PoseLandmark.RIGHT_SHOULDER] val wristL = landmarks[PoseLandmark.LEFT_WRIST] val wristR = landmarks[PoseLandmark.RIGHT_WRIST] val torsoScale = torsoScale(shoulderL, shoulderR, hipL, hipR) val dtMs = lastLoggedMs?.let { timestampMs - it } lastLoggedMs = timestampMs val line = "$timestampMs " + "${dtMs ?: "-"} " + "${format(ankleL)} ${format(ankleR)} " + "${format(hipL)} ${format(hipR)} " + "${format(shoulderL)} ${format(shoulderR)} " + "${format(wristL)} ${format(wristR)} " + "${torsoScale?.let { "%.1f".format(it) } ?: "-"} " + stepCount.toString() try { out.write(line) out.newLine() // Flush every line, not just on stop() -- if the app is force- // stopped mid-recording the file should still have everything // logged up to that point rather than losing a buffered tail. out.flush() } catch (_: Exception) { // A failed debug write shouldn't disrupt the actual recording. } } /** @brief Closes the current file, if one is open. Safe to call even if nothing is open. */ fun stop() { try { writer?.close() } catch (_: Exception) { // Nothing useful to do about a failed close on a debug file. } writer = null } private fun format(landmark: SmoothedLandmark?): String = if (landmark == null) "-" else "(%.1f,%.2f)".format(landmark.y, landmark.inFrameLikelihood) /** @brief Shoulder-to-hip pixel distance, matching [LiveStepDetector]'s own torso-scale definition. */ private fun torsoScale( shoulderL: SmoothedLandmark?, shoulderR: SmoothedLandmark?, hipL: SmoothedLandmark?, hipR: SmoothedLandmark?, ): Float? { val shoulder = shoulderL ?: shoulderR ?: return null val hip = hipL ?: hipR ?: return null val dx = shoulder.x - hip.x val dy = shoulder.y - hip.y return sqrt((dx * dx + dy * dy)).takeIf { it > 0f } } }