Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,23 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 33
versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0"
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 34
versionName = (project.findProperty("appVersionName") as String?) ?: "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// Repo the self-updater polls for new releases.
buildConfigField("String", "GITHUB_REPO", "\"foureight84/PulseLoopAndroid\"")

// Strava OAuth — credentials from local.properties (gitignored), fall back to
// empty placeholders so non-Strava builds compile without the file.
val localProps = Properties().apply {
val f = rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
buildConfigField("String", "STRAVA_CLIENT_ID",
"\"${localProps.getProperty("stravaClientId") ?: ""}\"")
buildConfigField("String", "STRAVA_CLIENT_SECRET",
"\"${localProps.getProperty("stravaClientSecret") ?: ""}\"")
}

buildFeatures {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Strava OAuth redirect: captures pulseloop://localhost/strava-auth?code=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pulseloop" android:host="localhost" android:pathPrefix="/strava-auth" />
</intent-filter>
</activity>

<!-- FileProvider for diagnostics export -->
Expand Down
66 changes: 66 additions & 0 deletions app/src/main/java/com/pulseloop/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pulseloop

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
Expand All @@ -10,7 +11,12 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.pulseloop.notifications.CoachNotifications
import com.pulseloop.strava.StravaAuth
import com.pulseloop.strava.StravaTokenStore
import com.pulseloop.ui.PulseLoopApp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
* Single-activity host for the PulseLoop Compose UI.
Expand Down Expand Up @@ -52,6 +58,9 @@ class MainActivity : ComponentActivity() {
setContent {
PulseLoopApp()
}

// Handle Strava redirect on cold start (app launched from browser callback).
handleStravaRedirect(intent)
}

override fun onResume() {
Expand All @@ -63,6 +72,63 @@ class MainActivity : ComponentActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleStravaRedirect(intent)
}

/**
* Strava OAuth callback (`pulseloop://localhost/strava-auth?…`). Reached via `onNewIntent` for
* the usual case, and via `onCreate` when the browser round-trip outlived our process.
*
* Failures are recorded in the token store rather than dropped, so the Strava settings screen
* can say what went wrong — a silent return leaves the user staring at a "Connect" button that
* appears to do nothing.
*/
private fun handleStravaRedirect(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "pulseloop") return
if (!StravaAuth.isConfigured) return
val store = StravaTokenStore(this)

// Strava returns `error=access_denied` when the user declines on the consent screen.
uri.getQueryParameter("error")?.takeIf { it.isNotBlank() }?.let { error ->
store.takePendingAuthState()
store.saveLastError(
if (error == "access_denied") "Strava authorization was declined." else "Strava denied authorization: $error"
)
return
}

// CSRF: the state we generated must come back. One-shot, cleared either way.
if (!StravaAuth.validateState(store, uri.getQueryParameter("state"))) {
store.saveLastError("Strava authorization could not be verified. Please try connecting again.")
return
}

// The consent screen lets the user untick "Upload your activities". Catch it here rather
// than letting every future upload fail with an opaque 401.
if (!StravaAuth.grantedScopeIncludesWrite(uri.getQueryParameter("scope"))) {
store.saveLastError("Strava did not grant upload permission. Reconnect and keep \"Upload your activities\" checked.")
return
}

val code = uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } ?: run {
store.saveLastError("Strava returned an unexpected authorization response.")
return
}

lifecycleScope.launch(Dispatchers.IO) {
try {
store.save(StravaAuth.exchangeCode(code))
store.clearLastError()
} catch (e: Exception) {
// The code may have expired, or the configured secrets are wrong.
store.saveLastError("Could not complete the Strava sign-in: ${e.message ?: "unknown error"}")
}
}
}

// ── Permission checks ──────────────────────────────────────────────

fun hasAllBlePermissions(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Expand Down
244 changes: 244 additions & 0 deletions app/src/main/java/com/pulseloop/data/DataArchive.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package com.pulseloop.data

import kotlinx.serialization.Serializable

@Serializable
data class PulseArchive(
val formatVersion: Int = 1,
val exportedAt: Long,
val appVersion: String,
val counts: Map<String, Int> = emptyMap(),
val devices: List<DeviceDTO> = emptyList(),
val measurements: List<MeasurementDTO> = emptyList(),
val activityDaily: List<ActivityDailyDTO> = emptyList(),
val activityBuckets: List<ActivityBucketDTO> = emptyList(),
val batterySamples: List<BatterySampleDTO> = emptyList(),
val deviceMeasurementConfigs: List<DeviceMeasurementConfigDTO> = emptyList(),
val activitySessions: List<ActivitySessionDTO> = emptyList(),
val activityGpsPoints: List<ActivityGpsPointDTO> = emptyList(),
val activityEvents: List<ActivityEventDTO> = emptyList(),
val activitySamples: List<ActivitySampleDTO> = emptyList(),
val activitySensorPolls: List<ActivitySensorPollDTO> = emptyList(),
val sleepSessions: List<SleepSessionDTO> = emptyList(),
val sleepStageBlocks: List<SleepStageBlockDTO> = emptyList(),
val coachConversations: List<CoachConversationDTO> = emptyList(),
val coachMessages: List<CoachMessageDTO> = emptyList(),
val coachMemories: List<CoachMemoryDTO> = emptyList(),
val coachToolCalls: List<CoachToolCallDTO> = emptyList(),
val userProfiles: List<UserProfileDTO> = emptyList(),
val userGoals: List<UserGoalDTO> = emptyList(),
val rawPackets: List<RawPacketDTO> = emptyList(),
val derivedUpdates: List<DerivedUpdateDTO> = emptyList(),
val coachSummaries: List<CoachSummaryDTO> = emptyList(),
val wearableLogs: List<WearableLogDTO> = emptyList(),
val coachNotificationRecords: List<CoachNotificationRecordDTO> = emptyList(),
/** iOS #96 nutrition. Not in iOS's own `DataArchive.swift` — see the note on [MealEntryDTO]. */
val mealEntries: List<MealEntryDTO> = emptyList(),
val foodProducts: List<CachedFoodProductDTO> = emptyList(),
)

@Serializable data class DeviceDTO(
val id: String, val name: String, val advertisedName: String? = null,
val peripheralIdentifier: String? = null, val bleAddressHint: String? = null,
val batteryPercent: Int? = null, val stateRaw: String, val deviceTypeRaw: String,
val wearableModelID: String? = null, val capabilitiesRaw: String,
val lastConnectedAt: Long? = null, val lastDisconnectedAt: Long? = null,
val lastSyncAt: Long? = null, val lastFullSyncAt: Long? = null,
val firmwareVersion: String? = null, val createdAt: Long, val updatedAt: Long,
)

@Serializable data class MeasurementDTO(
val id: String, val kindRaw: String, val value: Double, val unit: String,
val timestamp: Long, val sourceRaw: String, val confidenceRaw: String = "known",
val activitySessionId: String? = null, val rawPacketId: String? = null,
val createdAt: Long,
)

@Serializable data class ActivityDailyDTO(
val id: String, val date: Long, val steps: Int = 0, val calories: Double = 0.0,
val distanceMeters: Double = 0.0, val activeMinutes: Int = 0,
val source: String = "mock", val syncedAt: Long? = null,
val createdAt: Long, val updatedAt: Long,
val estimatedActiveCalories: Double? = null,
)

@Serializable data class ActivityBucketDTO(
val startEpoch: Long, val date: Long, val steps: Int = 0,
val distanceMeters: Double = 0.0, val source: String = "ring_history",
val updatedAt: Long,
)

@Serializable data class BatterySampleDTO(
val id: String, val percent: Int, val timestamp: Long, val createdAt: Long,
)

@Serializable data class DeviceMeasurementConfigDTO(
val deviceId: String, val hrIntervalMinutes: Int = 5, val hrEnabled: Boolean = true,
val spo2Enabled: Boolean = true, val stressEnabled: Boolean = true,
val hrvEnabled: Boolean = true, val temperatureEnabled: Boolean = true,
val updatedAt: Long,
)

@Serializable data class ActivitySessionDTO(
val id: String, val type: String, val statusRaw: String = "recording",
val startedAt: Long, val endedAt: Long? = null, val totalPauseSeconds: Double = 0.0,
val calories: Double? = null, val distanceMeters: Double? = null,
val avgHeartRate: Double? = null, val minHeartRate: Double? = null,
val maxHeartRate: Double? = null, val avgSpO2: Double? = null,
val latestSpO2: Double? = null, val notes: String? = null,
val useGps: Boolean = true, val perceivedEffort: String? = null,
val gpsPointCount: Int = 0, val rejectedGpsPointCount: Int = 0,
val hrPollCount: Int = 0, val hrPollFailureCount: Int = 0,
val spo2PollCount: Int = 0, val spo2PollFailureCount: Int = 0,
val liveActivityID: String? = null, val lastSensorPollAt: Long? = null,
val lastGpsPointAt: Long? = null, val stravaActivityId: Long? = null,
val createdAt: Long, val updatedAt: Long,
)

@Serializable data class ActivityGpsPointDTO(
val id: String, val sessionId: String, val latitude: Double, val longitude: Double,
val altitude: Double? = null, val horizontalAccuracy: Double? = null,
val speed: Double? = null, val course: Double? = null, val timestamp: Long,
val accepted: Boolean = true, val rejectionReason: String? = null,
)

@Serializable data class ActivityEventDTO(
val id: String, val sessionId: String, val kind: String, val timestamp: Long,
val payloadJSON: String? = null,
)

@Serializable data class ActivitySampleDTO(
val id: String, val sessionId: String, val measurementId: String? = null,
val kind: String, val value: Double, val unit: String, val timestamp: Long,
val source: String = "mock", val confidenceRaw: String = "known",
)

@Serializable data class ActivitySensorPollDTO(
val id: String, val sessionId: String, val timestamp: Long,
val kind: String, val status: String, val value: Double? = null,
val errorMessage: String? = null,
)

@Serializable data class SleepSessionDTO(
val id: String, val date: Long, val startAt: Long, val endAt: Long,
val totalMinutes: Int, val score: Int? = null, val syncedAt: Long? = null,
val sourceRaw: String = "ring", val createdAt: Long, val updatedAt: Long,
)

@Serializable data class SleepStageBlockDTO(
val id: String, val sessionId: String, val startAt: Long, val startMinute: Int,
val durationMinutes: Int, val stageRaw: String,
)

@Serializable data class CoachConversationDTO(
val id: String, val title: String = "Today check-in", val createdAt: Long,
val updatedAt: Long, val totalInputTokens: Int = 0, val totalOutputTokens: Int = 0,
val totalCostUSD: Double = 0.0,
)

@Serializable data class CoachMessageDTO(
val id: String, val conversationId: String, val role: String, val body: String,
val cardsJSON: String? = null, val pendingActionJSON: String? = null,
val attachmentsJson: String? = null, val createdAt: Long,
val inputTokens: Int? = null, val outputTokens: Int? = null,
val costUSD: Double? = null, val modelUsed: String? = null,
val providerUsed: String? = null,
)

@Serializable data class CoachMemoryDTO(
val id: String, val key: String, val value: String,
val memoryType: String = "note", val importance: Int = 3,
val expiresAt: Long? = null, val sourceMessageId: String? = null,
val isUserEditable: Boolean = true, val createdAt: Long, val updatedAt: Long,
)

@Serializable data class CoachToolCallDTO(
val id: String, val conversationId: String, val messageId: String? = null,
val toolName: String, val inputJSON: String? = null, val outputJSON: String? = null,
val label: String = "", val statusRaw: String = "success", val sequence: Int = 0,
val createdAt: Long,
)

@Serializable data class UserProfileDTO(
val id: String, val name: String? = null, val age: Int? = null,
val sex: String? = null, val heightCm: Double? = null, val weightKg: Double? = null,
val onboardingCompleted: Boolean = false, val baselineCompleted: Boolean = false,
val hrZoneModeRaw: String = "auto",
val hrRestingBaseline: Double? = null,
val hrRestingBaselineUpdatedAt: Long? = null,
val hrCustomLowUpper: Double? = null,
val hrCustomAthleticUpper: Double? = null,
val hrCustomElevatedStart: Double? = null,
val hrCustomHighStart: Double? = null,
val createdAt: Long, val updatedAt: Long,
)

@Serializable data class UserGoalDTO(
val id: String, val steps: Int = 10000, val distanceMeters: Double = 8000.0,
val calories: Int = 500, val sleepMinutes: Int = 480,
val activeMinutes: Int = 45, val workoutsPerWeek: Int = 4,
// iOS #96 intake goals. Defaulted so archives written before these existed still import.
val intakeCalories: Double? = null, val intakeProteinG: Double? = null,
val intakeCarbsG: Double? = null, val intakeFatG: Double? = null,
val nutritionEnabled: Boolean = false,
val updatedAt: Long,
)

@Serializable data class RawPacketDTO(
val id: String, val timestamp: Long, val directionRaw: String,
val commandId: Int, val hexPayload: String, val decodedKind: String? = null,
val decodedJSON: String? = null, val confidenceRaw: String = "unknown",
val createdAt: Long,
)

@Serializable data class DerivedUpdateDTO(
val id: String, val timestamp: Long, val kind: String,
val entityType: String, val entityId: String, val payloadJSON: String? = null,
)

@Serializable data class CoachSummaryDTO(
val id: String, val kind: String, val scopeKey: String,
val title: String, val body: String, val chipsJSON: String? = null,
val conversationId: String? = null, val dataSignature: String,
val createdAt: Long, val updatedAt: Long,
)

@Serializable data class WearableLogDTO(
val id: String, val timestamp: Long, val event: String,
val detail: String? = null, val deviceId: String? = null,
val categoryRaw: String? = null, val levelRaw: String? = null,
)

@Serializable data class CoachNotificationRecordDTO(
val id: String, val title: String, val body: String, val createdAt: Long,
)

/**
* iOS #96 meal log. **Android-originated — iOS's `DataArchive.swift` does not carry these.** Its
* exporter landed in the same week as the nutrition models and was never extended, so an iOS
* export→import silently drops the user's meals. Included here because the Android import dialog
* promises to "permanently delete everything currently in the app and replace it", and wiping
* meal_entries without restoring them would make that literally true in the worst way. Upstream
* candidate for iOS.
*/
@Serializable data class MealEntryDTO(
val id: String, val date: Long, val timestamp: Long, val name: String,
val mealTypeRaw: String = "snack", val calories: Double,
val proteinG: Double = 0.0, val carbsG: Double = 0.0, val fatG: Double = 0.0,
val fiberG: Double? = null, val sugarG: Double? = null, val sodiumMg: Double? = null,
val sourceRaw: String = "manual", val offProductCode: String? = null,
val servingDescription: String? = null, val servingGrams: Double? = null,
val quantity: Double = 1.0, val confidenceRaw: String = "medium",
val userEdited: Boolean = false, val notes: String? = null,
val loggedByCoach: Boolean = false, val createdAt: Long,
)

@Serializable data class CachedFoodProductDTO(
val code: String, val name: String, val brand: String? = null,
val energyKcal100g: Double, val protein100g: Double = 0.0,
val carbs100g: Double = 0.0, val fat100g: Double = 0.0,
val fiber100g: Double? = null, val sugars100g: Double? = null,
val saturatedFat100g: Double? = null, val sodiumMg100g: Double? = null,
val servingSizeText: String? = null, val servingQuantityG: Double? = null,
val lastUsedAt: Long, val useCount: Int = 0,
)
Loading
Loading