Compare commits

...

3 Commits
v0.1.0 ... main

Author SHA1 Message Date
Mike Kell 86a7cd9852 fix(android,health): unblock Health Connect grant flow and cloud-backed sync
Field-reported on the alpha APK: the Samsung/Pixel row failed with
'Health permissions were denied' without ever showing a dialog, and the
Google Health connect reported the same error despite a successful OAuth.

- MainActivity now extends FlutterFragmentActivity: package:health v13
  casts the host activity to androidx ComponentActivity to call
  registerForActivityResult, so with plain FlutterActivity the permission
  launcher stayed null and requestAuthorization() silently returned false
  (HealthPlugin.kt:317 'Permission launcher not found')
- add the androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE intent-filter
  required by the health package README on the main activity
- drop STEPS / READ_STEPS from the Android permission set: steps are never
  fetched anywhere and the README ties step access to ACTIVITY_RECOGNITION,
  which this app does not declare (weight, total + active calories, blood
  glucose remain, all declared in the manifest)
- syncFromHealth treats a native permission denial as fatal only when no
  cloud partner holds a grant, so a Google Health-connected sync proceeds
  without the OS permission (native reads degrade to empty)
- tests: biometric controller covers native-denial + cloud-ready sync
  (cloudReady fake override); suite 185/185, analyze clean, debug APK
  builds (also clears the standing flutter build apk --debug gate)
- BUILD_STATE: record both fixes, check off the Android OAuth client and
  connect gates, add the Health Connect on-device verification gate
2026-10-18 11:31:50 -04:00
Mike Kell 0a77b13032 ci(release): fixed google callback authentication 2026-10-18 10:29:06 -04:00
Mike Kell a40589cc89 ci(release): add alpha release pipeline, signing config, and publish tooling
- .forgejo/workflows/release.yaml: tag-triggered (v*) pipeline that builds a
  split-per-abi Android arm64 APK (signed via ANDROID_KEYSTORE_BASE64 /
  ANDROID_KEY_PROPERTIES secrets), a Windows MSIX plus portable zip, and
  attaches every asset to a Forgejo prerelease through the releases API;
  Flutter pinned to 3.47.x because pubspec.lock requires flutter >=3.44.0
- scripts/publish_alpha.ps1: local one-shot build+publish equivalent
  (codegen, APK, portable zip, MSIX, release + asset upload under dist/v*)
- android: release signing wired to key.properties with minify, resource
  shrinking, and new proguard-rules.pro (Flutter embedding, SQLCipher, and
  JSON keep rules)
- pubspec: msix ^3.16.7 dev dependency and msix_config publisher identity
- scripts/test.ps1: Forgejo asset-upload probe reading the token from env;
  the hardcoded token was removed and its rotation is tracked as a
  BUILD_STATE.md verification gate
- gitignore: /dist/, **/android/build/, and android/key.properties
2026-10-18 08:11:23 -04:00
17 changed files with 689 additions and 42 deletions

View File

@ -0,0 +1,167 @@
name: Build and Publish Alpha Releases
on:
push:
tags:
- 'v*'
jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.47.x'
channel: 'stable'
cache: true
- name: Install Dependencies
run: flutter pub get
- name: Run Code Generation
run: dart run build_runner build --delete-conflicting-outputs
- name: Decode Android Keystore & Key Properties
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEY_PROPERTIES: ${{ secrets.ANDROID_KEY_PROPERTIES }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
echo "$KEY_PROPERTIES" > android/key.properties
fi
- name: Build Split APKs
run: flutter build apk --release --split-per-abi
- name: Rename and Stage APK
run: |
mkdir -p dist
TAG_NAME=${GITHUB_REF#refs/tags/}
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk "dist/GLP1Companion-Android-arm64-${TAG_NAME}.apk"
- name: Upload Artifact for Release
uses: actions/upload-artifact@v4
with:
name: android-release-apk
path: dist/*.apk
retention-days: 1
build-windows:
name: Build Windows MSIX & Portable
runs-on: windows-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.47.x'
channel: 'stable'
cache: true
- name: Install Dependencies
shell: pwsh
run: flutter pub get
- name: Run Code Generation
shell: pwsh
run: dart run build_runner build --delete-conflicting-outputs
- name: Build Windows Native Release
shell: pwsh
run: flutter build windows --release
- name: Generate MSIX Installer
shell: pwsh
run: dart run msix:create --install-certificate false
- name: Package Portable Zip & Stage MSIX
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
$tag = $env:GITHUB_REF -replace 'refs/tags/', ''
# Copy MSIX
Copy-Item "build\windows\x64\runner\Release\*.msix" "dist\GLP1Companion-Windows-${tag}.msix" -Force
# Compress standalone runner folder
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath "dist\GLP1Companion-Windows-x64-${tag}-Portable.zip" -Force
- name: Upload Artifacts for Release
uses: actions/upload-artifact@v4
with:
name: windows-release-packages
path: dist/*
retention-days: 1
publish-release:
name: Attach Assets to Forgejo Release
needs: [build-android, build-windows]
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Download Staged Artifacts
uses: actions/download-artifact@v4
with:
path: staged-assets
- name: Create Forgejo Release and Upload Assets
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
REPO="${{ github.repository }}"
SERVER="${{ github.server_url }}"
echo "Target Release: $TAG_NAME on $REPO"
# 1. Fetch or Create Release
RELEASE_JSON=$(curl -s -X POST "$SERVER/api/v1/repos/$REPO/releases" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG_NAME\",
\"name\": \"GLP-1 Companion $TAG_NAME\",
\"body\": \"Automated alpha release build for $TAG_NAME.\",
\"draft\": false,
\"prerelease\": true
}")
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo "Release may already exist, fetching existing release ID..."
RELEASE_JSON=$(curl -s "$SERVER/api/v1/repos/$REPO/releases/tags/$TAG_NAME" \
-H "Authorization: token $FORGEJO_TOKEN")
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
fi
echo "Release ID: $RELEASE_ID"
# 2. Upload All Staged Artifacts to Release
find staged-assets -type f \( -name "*.apk" -o -name "*.msix" -o -name "*.zip" \) | while read -r FILE; do
FILENAME=$(basename "$FILE")
echo "Uploading $FILENAME to release $RELEASE_ID..."
curl -s -X POST "$SERVER/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILENAME" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$FILE" > /dev/null
done
echo "All release assets uploaded successfully."

3
.gitignore vendored
View File

@ -34,6 +34,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/dist/
# Web specific
@ -54,6 +55,8 @@ macos/Flutter/ephemeral/
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
**/android/build/
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside

View File

@ -2,8 +2,8 @@
## Active Target
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
- **Latest Addendum:** Phase 4B — Private Recipe Sources & Mealie Integration (2026-10-17)
- **Status:** Phase 7 Complete (all phases 1–7 done); alpha release pipeline landed 2026-10-18
- **Latest Addendum:** Alpha Release Pipeline & Scripting (2026-10-18)
## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
@ -196,4 +196,51 @@
- [x] `flutter analyze` — No issues found.
- [x] Codegen: `dart run build_runner build` (`flutter pub run` is retired on current Flutter; `--delete-conflicting-outputs` is now ignored by build_runner) — drift re-emits `app_database.g.dart` byte-identically (no schema change).
- [x] `flutter test` — 184/184 green (170 pre-existing + 14 new Mealie tests), including the 390 dp mobile navigation suite.
- [ ] Manual Mealie round trip on a reachable instance: Settings → Private Recipe Integrations → Connect Mealie (URL + API token) → Test & Save → badge flips to Connected → Sync Now (or the Recipe Vault AppBar sync action) → vault shows the Mealie badge and the Mealie/Web Scraped filter chips isolate each source.
- [ ] Manual Mealie round trip on a reachable instance: Settings → Private Recipe Integrations → Connect Mealie (URL + API token) → Test & Save → badge flips to Connected → Sync Now (or the Recipe Vault AppBar sync action) → vault shows the Mealie badge and the Mealie/Web Scraped filter chips isolate each source.
## Alpha Release Pipeline & Tooling (2026-10-18)
Release engineering for alpha (pre-release) builds across Android and Windows, driven either locally (one-shot PowerShell script) or by CI when a version tag is pushed.
- [x] CI: `.forgejo/workflows/release.yaml` — "Build and Publish Alpha Releases", triggered on `push` of tags matching `v*`. Three jobs:
- `build-android` (ubuntu): Java 17 (Temurin) + Flutter 3.47.x → `flutter pub get` → `dart run build_runner build --delete-conflicting-outputs` → decode `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEY_PROPERTIES` repo secrets into `android/app/upload-keystore.jks` + `android/key.properties` (step skipped when secrets are absent) → `flutter build apk --release --split-per-abi` → stages `GLP1Companion-Android-arm64-<tag>.apk` under `dist/`.
- `build-windows` (windows): Flutter 3.47.x → `flutter pub get` + codegen → `flutter build windows --release` → `dart run msix:create --install-certificate false` → stages `GLP1Companion-Windows-<tag>.msix` and `GLP1Companion-Windows-x64-<tag>-Portable.zip` (runner Release folder zipped via `Compress-Archive`).
- `publish-release` (needs both build jobs): downloads the staged artifacts and drives the Forgejo REST API (`POST /api/v1/repos/{owner}/{repo}/releases`, then `/releases/{id}/assets?name=`) with `GITHUB_TOKEN` to create the release with `prerelease: true` — falling back to the existing release for the tag if creation fails — and uploads every `.apk` / `.msix` / `.zip`.
- Flutter pinned to 3.47.x to match the dev toolchain: `pubspec.lock` declares `flutter: ">=3.44.0"`, so an older CI toolchain (e.g. the originally drafted 3.24.x) fails at `flutter pub get`.
- [x] Local script: `scripts/publish_alpha.ps1 -Version <x.y.z> [-ForgejoToken <token>]` (token defaults to `$env:FORGEJO_TOKEN`; server `https://git.kellsupport.com`, repo `mtkell/glp-companion`) — same flow end to end: codegen → arm64 APK → portable zip → MSIX → Forgejo release (`prerelease: true`) with asset upload; artifacts land in `dist/v<version>/`.
- [x] Forgejo API probe: `scripts/test.ps1` verifies release lookup/creation and `multipart/form-data` asset upload against a throwaway draft release (`v0.0.1-test`) with a dummy text asset; the token is read from `$env:FORGEJO_TOKEN` only (the previously hardcoded token was removed — rotation tracked in the gates below).
- [x] Android release signing: `android/app/build.gradle.kts` loads `android/key.properties` from the project root when present and wires a `release` signing config (alias / password / store) with `isMinifyEnabled` + `isShrinkResources` and `proguard-rules.pro`; when the file is absent the properties are empty (CI decodes them from secrets; local release builds need their own `key.properties`).
- [x] ProGuard rules (`android/app/proguard-rules.pro`): keep Flutter embedding/plugin/util/view classes, `net.sqlcipher.**` + `net.sqlcipher.database.**` (SQLCipher backing the encrypted drift DB), annotation/enum/signature metadata for JSON serialization, plus `-dontwarn` suppressions for Play Core deferred components, conscrypt, and javax.annotation.
- [x] MSIX packaging: `msix: ^3.16.7` dev dependency (resolved 3.18.0) + `msix_config` block in `pubspec.yaml` — display name "GLP-1 Companion", publisher "Kell Engineering LLC" (`CN=Kell Engineering LLC, O=..., L=Valrico, S=Florida, C=US`), identity `KellEngineering.GLP1Companion`, `msix_version 0.1.0.0`, `logo_path: web/icons/Icon-512.png`, `internetClient` capability, execution alias `glp-companion`.
- [x] Secret & artifact hygiene: `/dist/` (local release artifacts, ~107 MB), `**/android/build/` (Gradle problems report), and `**/android/key.properties` added to `.gitignore`; the hardcoded Forgejo token stripped from `scripts/test.ps1` before it could ever reach git history.
## Alpha Release Verification Gates (2026-10-18, to run)
- [ ] **Rotate the Forgejo token** that was hardcoded in `scripts/test.ps1`: Forgejo → Settings → Applications → delete the exposed token, mint a new one, export it as `FORGEJO_TOKEN` locally, and store it as a repo secret. (The old token never entered git history, but it existed in plaintext on disk.)
- [ ] Configure Forgejo Actions secrets on `mtkell/glp-companion`: `ANDROID_KEYSTORE_BASE64` (base64 of `upload-keystore.jks`) and `ANDROID_KEY_PROPERTIES` (keyAlias / keyPassword / storeFile=`upload-keystore.jks` / storePassword). Without them the Android job's release build fails at signing.
- [ ] Push a `v*` tag (e.g. `git tag v0.1.0 && git push origin v0.1.0`) and confirm all three workflow jobs go green and the prerelease at `https://git.kellsupport.com/mtkell/glp-companion/releases` carries the APK, MSIX, and portable zip assets.
- [ ] Install-gate: sideload the arm64 APK on an Android device and install the MSIX on Windows; launch both and confirm the encrypted drift DB opens (the minify/shrink build is exactly why the SQLCipher keep-rules exist).
## Post-Alpha Fixes (2026-10-18)
- [x] Android Google Health connect fix (field-reported alpha: authorization completed in the browser, but on return the app "only shows the localhost call" — Connect stayed highlighted and the row never flipped to Connected). Root cause: `GoogleHealthConnector.authorize()` ran the RFC 8252 **loopback redirect** on Android too — it bound `HttpServer` on the device loopback and needed the *backgrounded* app to serve Chrome's `http://localhost:52789/callback` after consent, which Android's cached-app freezer prevents; the callback never arrived, so `authorize()` hung (button `busy`) until the 5-minute timeout. Google deprecates loopback for mobile client types, and custom-scheme redirects are rejected on the existing Web-type client ("Custom scheme URIs are not allowed for 'WEB' client type"), so the fix follows Google's recommended Android path (Google Identity Services):
- Connector (`google_health_connector_io.dart`): on `Platform.isAndroid` only, `authorize()` now runs **`package:google_sign_in`** (native account chooser + consent sheet — the app stays foreground, no HTTP callback) with `serverClientId = GOOGLE_HEALTH_CLIENT_ID` and `forceCodeForRefreshToken: true` (the `prompt=consent` equivalent), then exchanges the returned one-time `serverAuthCode` on the shared token endpoint (GIS codes carry no `redirect_uri`) — secure-storage tokens, transparent refresh, REST reads, and the parser are untouched. A 5-minute timeout mirrors the loopback path; a missing code logs the Android-client/SHA-1 checklist. `disconnect()` additionally signs the SDK out (best-effort) so Connect re-prompts for an account. Desktop/iOS keep the proven loopback flow.
- UX: the Google Health row's failure copy is now OAuth-accurate — "Google Health sign-in was cancelled or did not complete. Tap Connect to try again." — instead of pointing the user at device settings (integrations controller branches on `HealthProviderType.googleHealth`).
- Dependency: `google_sign_in: 6.3.0` added (Android implementation 6.2.1).
- Verified: `flutter analyze` clean; `flutter test` 184/184 green (including `integrations_controller_test.dart` and `settings_screen_test.dart`).
- [x] Android post-connect sync fix (field-reported right after the GIS fix: Google Health **connected**, but the app then showed "Health permissions were denied."): `BiometricController.syncFromHealth()` unconditionally demanded the **native** Health Connect grant first and bailed on denial — never reaching the cloud connector, which needs no OS permission. The bail is now `!granted && !await gateway.isCloudSyncReady()` — a native denial is fatal only when no cloud partner holds a grant; with Google Health connected, cloud reads proceed (native degrades to empty while ungranted) and the status reports `Synced N weight sample(s)…`. Tests: `biometric_controller_test.dart` gains a `cloudReady` fake override plus "native denial with a connected cloud partner still syncs partner data" (11 tests in file); full suite 185/185, analyze clean. Still prompts for Health Connect once — a denial there no longer breaks the cloud sync.
## Google Health on Android — Verification Gates (2026-10-18, manual)
- [x] Google Cloud Android OAuth client(s) — DONE 2026-10-18 (the on-device connect succeeded, proving a matching package/SHA-1 client exists): Google Cloud → the same project as `GOOGLE_HEALTH_CLIENT_ID` → APIs & Services → Credentials → **Create OAuth client ID → Application type Android**: package name `com.companionglp.glpcompanion` with the SHA-1 of the release `upload-keystore.jks` (`keytool -list -v -keystore upload-keystore.jks -alias <keyAlias>`); add a second Android client with the debug keystore SHA-1 (`%USERPROFILE%\.android\debug.keystore`, alias `androiddebugkey`, storepass `android`) for debug builds. The GIS SDK resolves the caller by package/SHA-1 — without a match it returns no `serverAuthCode` and Connect fails (the connector logs the checklist in debug). Fingerprints computed 2026-10-18 (re-verify against the installed APK with `keytool -printcert -jarfile <apk>`): release `upload-keystore.jks` alias `upload` = `79:8B:73:52:70:77:C3:46:C7:12:06:88:42:0E:97:67:CC:B8:E2:85`; debug `~/.android/debug.keystore` alias `androiddebugkey` = `67:BA:FE:4A:15:5B:52:73:E1:8F:0D:F7:44:81:7B:5C:72:C3:A1:36`.
- [x] On-device connect round trip (confirmed 2026-10-18): install the rebuilt arm64 APK → Settings → Google Health → Connect → native account chooser → Google consent (activity & fitness + health metrics read scopes) → returns straight into the app → row flips **Connected**.
- [ ] Data round trip (pending an APK rebuild containing the post-connect sync fix above): after Connect, status reads `Synced N weight sample(s)…` instead of "Health permissions were denied.", and weight/active energy appear on the Nutrition dashboard (Android imports persist under the `healthConnect` platform source).
## Health Connect (Samsung & Pixel) Row — Fix (2026-10-18)
- [x] Native Health Connect permission request failed silently (field-reported: tapping the "Health Connect (Samsung & Pixel)" row showed "Health permissions were denied. Enable them in your device settings" with **no permission dialog ever appearing**). Root cause per the `package:health` v13.3.1 README: `requestAuthorization()` registers its permission launcher via `(activity as ComponentActivity).registerForActivityResult(...)` in `onAttachedToActivity` — the host activity must therefore extend **`FlutterFragmentActivity`**, but `MainActivity` extended plain `FlutterActivity` (`android.app.Activity`, not androidx `ComponentActivity`). The cast fails → the launcher stays `null` → the plugin answers `success(false)` immediately (HealthPlugin.kt:317) → our gateway maps it to "denied". Three fixes:
- `MainActivity.kt` now extends `FlutterFragmentActivity` (with a comment explaining the cast requirement). Drop-in for all other plugins (app_links, supabase, url_launcher, notifications, google_sign_in all bind to any Activity).
- `AndroidManifest.xml`: added the README-required `<intent-filter>` for `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` on `.MainActivity` (the `<queries>` block and `ViewPermissionUsageActivity` alias were already present; `health_connect_config.xml` is optional restore tooling and was not added).
- Read-set hygiene: `STEPS` removed from `_androidReadTypes` and `READ_STEPS` from the manifest — steps are **never fetched** anywhere in the app, and the README ties step access to the `ACTIVITY_RECOGNITION` permission this app does not declare; an ungrantable entry risks poisoning the whole permission request. Remaining set: weight, total calories burned, active energy burned, blood glucose (all four declared in the manifest).
- Verified: `flutter analyze` clean; `flutter test` 185/185 green; **`flutter build apk --debug` → `Built build\app\outputs\flutter-apk\app-debug.apk`** (also clears the standing build-gate item for the debug APK; Gradle 135.1 s; pre-existing KGP warning for device_info_plus/health/mobile_scanner is non-fatal today).
- [ ] On-device Health Connect round trip: tap the Samsung/Pixel row → the Health Connect permission screen **appears** (weight, calories, glucose toggles) → grant → row flips **Connected** → status shows a `Synced …` result. If a stale denial exists from earlier attempts, clear it once under Android Settings → Apps → glpcompanion → Permissions (or the Health Connect app's App permissions screen), then retry in-app.
- [ ] Disconnect then Connect again re-prompts for an account (SDK sign-out path), and the row recovers cleanly if the sign-in sheet is dismissed (cancel → the OAuth-specific error banner, button un-busy).
- [ ] Follow-up (not part of this fix): iOS still uses the loopback redirect and carries the same background fragility — migrate it to the SDK path when iOS alpha starts (needs an iOS-type OAuth client + reversed-client-ID URL scheme).
- [ ] Web-client housekeeping (field-checked 2026-10-18: the console screenshot showed the WRONG client — the project has **two** Web clients): `GOOGLE_HEALTH_CLIENT_ID` = `305941286539-2bqe3cte3gg6b2m64k0uosdp3tvrcfhq…` is the **Google Health** client — confirm it holds `http://localhost:52789/callback` under Authorized redirect URIs (desktop/iOS loopback only; the Android GIS flow uses **no** redirect URI) and that its secret matches `GOOGLE_HEALTH_CLIENT_SECRET`. The other client, `305941286539-8p9itkud4m0r9luiu53rg2s9liqon7lm…` ("GLP-1 Companion Web", redirect `https://pyrgjdvqbjzakuwvbpxw.supabase.co/auth/v1/callback`), is the **Supabase sign-in** client — leave it untouched. Secrets are no longer viewable in the console — rotate via **Add secret** + `.env` update if mismatched.

View File

@ -1,3 +1,12 @@
import java.io.FileInputStream
import java.util.Properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
@ -19,36 +28,43 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.companionglp.glpcompanion"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
//
// API 26 (Android 8.0) floor required by the `health` plugin (Health
// Connect / HealthKit bridge), which declares minSdk 26 in its
// manifest; Health Connect itself only ships on Android 8.0+.
minSdk = maxOf(flutter.minSdkVersion, 26)
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
getByName("release") {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
@ -58,4 +74,4 @@ flutter {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
}

23
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,23 @@
# Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Drift / SQLCipher
-keep class net.sqlcipher.** { *; }
-keep class net.sqlcipher.database.** { *; }
# OpenFoodFacts & JSON Serialization
-keepattributes *Annotation*,EnclosingMethod,Signature
-keepclassmembers enum * { *; }
# Flutter Play Store Split / Deferred Components (referenced by engine, not used here)
-dontwarn com.google.android.play.core.**
# Additional standard Flutter & third-party suppressions
-dontwarn io.flutter.embedding.engine.deferredcomponents.**
-dontwarn javax.annotation.**
-dontwarn org.conscrypt.**

View File

@ -4,11 +4,12 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching
the HealthGateway request set (weight, steps, total calories burned,
active energy burned, blood glucose). Health Connect federates
Samsung Health and Pixel sensor feeds through this runtime. -->
the HealthGateway request set (weight, total calories burned, active
energy burned, blood glucose). STEPS was removed from the set: it is
never fetched, and the package:health README ties step access to the
ACTIVITY_RECOGNITION permission this app does not declare. Health
Connect federates Samsung Health and Pixel sensor feeds. -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
@ -46,6 +47,12 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.companionglp.glpcompanion"/>
</intent-filter>
<!-- Health Connect (package:health README): lets the Health
Connect permissions screen resolve this app for the
rationale step of the permission flow. -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,5 +1,11 @@
package com.companionglp.glpcompanion
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity()
// FlutterFragmentActivity (not the default FlutterActivity) is required by
// package:health v13: it casts the host activity to
// androidx.activity.ComponentActivity to call registerForActivityResult for
// the Health Connect permission flow. With plain FlutterActivity that cast
// fails, the permission launcher stays null, and requestAuthorization()
// silently returns false ("Health permissions were denied" with no dialog).
class MainActivity : FlutterFragmentActivity()

View File

@ -7,6 +7,7 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
@ -54,18 +55,30 @@ class GoogleHealthTokens {
}
}
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
/// the system browser with a loopback redirect
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
/// transparent refresh, and normalized weight / active-energy reads from the
/// Google Health API — the official successor to the retired Fitbit Web API
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
/// Health.
/// Google Health cloud connector: OAuth2 through Google Identity Services on
/// Android (native account chooser + consent, `serverAuthCode` exchange) and
/// authorization-code + PKCE through the system browser with a loopback
/// redirect (`http://localhost:<port>/callback`) on desktop/iOS, backed by
/// secure-storage tokens with transparent refresh, and normalized weight /
/// active-energy reads from the Google Health API — the official successor to
/// the retired Fitbit Web API (shutdown 2026-10-30); Fitbit, Pixel and Wear
/// OS data flows into Google Health.
///
/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
/// them in a Google Cloud project with the Google Health API enabled; the
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
/// Requires `GOOGLE_HEALTH_CLIENT_ID` (a **Web application** OAuth client)
/// and `GOOGLE_HEALTH_CLIENT_SECRET` in `.env` (optionally
/// `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create them in a Google
/// Cloud project with the Google Health API enabled; the loopback redirect
/// must be an Authorized redirect URI on the OAuth client. Android also
/// needs an **Android** OAuth client (package `com.companionglp.glpcompanion`
/// + the signing certificate's SHA-1) in the same project, because the SDK
/// resolves the caller by package/SHA-1 before issuing a server auth code.
///
/// Android deliberately avoids the loopback redirect: the callback requires
/// the *backgrounded* app to serve HTTP to the browser, and Android's
/// cached-app freezer prevents that — the browser only ever showed the
/// `localhost` URL and the Connect button hung until the 5-minute timeout.
/// Google likewise deprecates loopback for mobile client types and
/// recommends this SDK path instead.
class GoogleHealthConnector implements ICloudHealthConnector {
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
: _client = client ?? http.Client(),
@ -138,6 +151,14 @@ class GoogleHealthConnector implements ICloudHealthConnector {
return false;
}
// Android runs the Google Identity Services SDK path instead of the
// loopback redirect: that flow needs the browser to reach an HTTP server
// held by this (backgrounded) app, which Android's process freezer
// prevents — the callback never arrived and authorize() hung.
if (Platform.isAndroid) {
return _authorizeViaGoogleSignIn();
}
final verifier = _randomUrlSafe(64);
final authorizeUrl = Uri.parse(
'$_authorizeEndpoint?response_type=code'
@ -179,9 +200,73 @@ class GoogleHealthConnector implements ICloudHealthConnector {
}
}
/// Android authorization through the Google Identity Services SDK
/// (`package:google_sign_in`): the consent sheet renders natively inside a
/// foreground activity, so the round trip no longer depends on serving a
/// loopback HTTP callback from the backgrounded app.
///
/// The SDK hands back a one-time [GoogleSignInAccount.serverAuthCode]
/// issued against the same Web client (`serverClientId`), which is
/// exchanged on the shared token endpoint — yielding the identical
/// access/refresh token pair the desktop loopback flow produces, so token
/// storage, transparent refresh, and the REST layer stay unchanged.
/// [forceCodeForRefreshToken] mirrors `prompt=consent` by guaranteeing a
/// refresh token on every re-consent.
Future<bool> _authorizeViaGoogleSignIn() async {
try {
final signIn = GoogleSignIn(
scopes: _scopes.split(' '),
serverClientId: _clientId,
forceCodeForRefreshToken: true,
);
final account = await signIn.signIn().timeout(_callbackTimeout);
if (account == null) {
debugPrint('GoogleHealthConnector: Google sign-in was cancelled.');
return false;
}
final code = account.serverAuthCode;
if (code == null || code.isEmpty) {
debugPrint(
'GoogleHealthConnector: Google sign-in returned no authorization '
'code - verify the Android OAuth client (package '
'com.companionglp.glpcompanion + signing-cert SHA-1) exists in the '
'same Google Cloud project as GOOGLE_HEALTH_CLIENT_ID.',
);
return false;
}
// Google-issued SDK codes are exchanged without a redirect_uri (per
// the GIS server-auth-code flow); everything else matches the shared
// [_requestTokens] path used by desktop.
final tokens = await _requestTokens({
'grant_type': 'authorization_code',
'code': code,
});
await _writeTokens(tokens);
return true;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'GoogleHealthConnector: Android sign-in failed ($error)',
);
return false;
}
}
@override
Future<void> disconnect() async {
await _storage.delete(key: tokenStorageKey);
if (Platform.isAndroid) {
// Best-effort: drop the SDK session too, so the next Connect re-prompts
// for an account instead of silently reusing the previous grant.
try {
await GoogleSignIn(
scopes: _scopes.split(' '),
serverClientId: _clientId,
).signOut();
} catch (error) {
debugPrint('GoogleHealthConnector: SDK sign-out failed ($error)');
}
}
}
@override

View File

@ -81,7 +81,6 @@ class MobileHealthGateway extends IHealthGateway {
/// Pixel sensor feeds into this single runtime.
static const List<HealthDataType> _androidReadTypes = [
HealthDataType.WEIGHT,
HealthDataType.STEPS,
HealthDataType.TOTAL_CALORIES_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.BLOOD_GLUCOSE,

View File

@ -219,9 +219,9 @@ class BiometricController extends AsyncNotifier<BiometricState> {
/// same source/window is replaced, not duplicated).
///
/// Returns `false` when no sync source exists on this platform (Web with
/// no cloud connector provisioned), when permission is denied, or when
/// the sync throws; the reason is reflected in
/// [BiometricState.statusMessage].
/// no cloud connector provisioned), when native permission is denied
/// *and* no cloud partner holds a valid grant, or when the sync throws;
/// the reason is reflected in [BiometricState.statusMessage].
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
final gateway = ref.read(healthGatewayProvider);
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
@ -245,10 +245,14 @@ class BiometricController extends AsyncNotifier<BiometricState> {
try {
// Native stores need an OS permission grant first; cloud connectors
// manage their own OAuth grant at connect time.
// manage their own OAuth grant at connect time. A native denial is
// therefore fatal only when no cloud partner can supply the data —
// with Google Health connected, `isCloudSyncReady()` is true and the
// cloud reads proceed without any OS permission (the gateway merges
// both sources; native degrades to empty while ungranted).
if (gateway.supportsNativeHealth) {
final granted = await gateway.requestPermissions();
if (!granted) {
if (!granted && !await gateway.isCloudSyncReady()) {
await _refresh(
statusMessage: 'Health permissions were denied.',
error: StateError('Health permissions denied'),

View File

@ -203,8 +203,14 @@ class IntegrationsController extends Notifier<IntegrationsState> {
isRequestingPermissions: false,
errorMessage: granted
? null
: 'Health permissions were denied. Enable them in your device '
'settings to sync biometrics.',
: provider == HealthProviderType.googleHealth
// OAuth round trip (SDK on Android, browser loopback on
// desktop): nothing to enable in device settings — the flow
// was cancelled or the redirect never completed.
? 'Google Health sign-in was cancelled or did not complete. '
'Tap Connect to try again.'
: 'Health permissions were denied. Enable them in your device '
'settings to sync biometrics.',
);
} catch (error, stackTrace) {
debugPrintStack(

View File

@ -9,6 +9,7 @@ import app_links
import device_info_plus
import flutter_local_notifications
import flutter_secure_storage_macos
import google_sign_in_ios
import mobile_scanner
import shared_preferences_foundation
import url_launcher_macos
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@ -49,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args:
dependency: transitive
description:
@ -185,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
console:
dependency: transitive
description:
name: console
sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a
url: "https://pub.dev"
source: hosted
version: "4.1.0"
convert:
dependency: transitive
description:
@ -437,6 +453,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.1"
get_it:
dependency: transitive
description:
name: get_it
sha256: "2b5c6b077aad30bba9782178baff6076a2044141f1d5f2f15b43bca3fbe74874"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
glob:
dependency: transitive
description:
@ -453,6 +477,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.8.1"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
url: "https://pub.dev"
source: hosted
version: "6.2.1"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
url: "https://pub.dev"
source: hosted
version: "5.9.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
url: "https://pub.dev"
source: hosted
version: "0.12.4+4"
gotrue:
dependency: transitive
description:
@ -517,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
intl:
dependency: transitive
description:
@ -653,6 +733,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.2.3"
msix:
dependency: "direct dev"
description:
name: msix
sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7"
url: "https://pub.dev"
source: hosted
version: "3.18.0"
native_toolchain_c:
dependency: transitive
description:
@ -789,6 +877,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
postgrest:
dependency: transitive
description:

View File

@ -55,6 +55,7 @@ dependencies:
sqlite3: ^3.4.0
# Scheduling support for flutter_local_notifications (zonedSchedule).
timezone: ^0.9.4
google_sign_in: 6.3.0
dev_dependencies:
flutter_test:
@ -67,7 +68,18 @@ dev_dependencies:
# Riverpod 3 migration.
build_runner: ^2.16.0
drift_dev: ^2.35.1
msix: ^3.16.7
msix_config:
display_name: GLP-1 Companion
publisher_display_name: Kell Engineering LLC
identity_name: KellEngineering.GLP1Companion
publisher: CN=Kell Engineering LLC, O=Kell Engineering LLC, L=Valrico, S=Florida, C=US
msix_version: 0.1.0.0
logo_path: web/icons/Icon-512.png
capabilities: internetClient
execution_alias: glp-companion
# Compile SQLCipher into the SQLite engine bundled by package:sqlite3's build
# hooks (the sqlcipher_flutter_libs plugin is retired on sqlite3 3.x).
hooks:

92
scripts/publish_alpha.ps1 Normal file
View File

@ -0,0 +1,92 @@
param(
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$ForgejoToken =$env:FORGEJO_TOKEN,
[string]$Server = "https://git.kellsupport.com",
[string]$Owner = "mtkell",
[string]$Repo = "glp-companion"
)
$ErrorActionPreference = "Stop"
if (-not $ForgejoToken) {
Write-Error "Forgejo Token missing! Pass -ForgejoToken <token> or set `$env:FORGEJO_TOKEN."
}
$DistDir = "dist\v$Version"
$Tag = "v$Version"
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host " Building & Publishing Alpha Release: $Tag" -ForegroundColor Cyan
Write-Host " Target: $Server/$Owner/$Repo" -ForegroundColor Cyan
Write-Host "==================================================" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
Write-Host "`n[1/5] Running Drift build_runner..." -ForegroundColor Yellow
dart run build_runner build --delete-conflicting-outputs
Write-Host "`n[2/5] Building Android arm64 APK..." -ForegroundColor Yellow
flutter build apk --release --split-per-abi
$ApkSrc = "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk"
$ApkDest = "$DistDir\GLP1Companion-Android-arm64-$Tag.apk"
Copy-Item $ApkSrc $ApkDest -Force
Write-Host "`n[3/5] Building Windows Release & Packaging Standalone Zip..." -ForegroundColor Yellow
flutter build windows --release
$ZipDest = "$DistDir\GLP1Companion-Windows-x64-$Tag-Portable.zip"
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath $ZipDest -Force
Write-Host "`n[4/5] Building Windows MSIX..." -ForegroundColor Yellow
dart run msix:create --install-certificate false
$MsixSrc = Get-ChildItem "build\windows\x64\runner\Release\*.msix" | Select-Object -First 1
$MsixDest = "$DistDir\GLP1Companion-Windows-$Tag.msix"
Copy-Item $MsixSrc.FullName $MsixDest -Force
Write-Host "`n[5/5] Publishing Release & Assets to Forgejo..." -ForegroundColor Yellow
$Headers = @{
"Authorization" = "token $ForgejoToken"
"Accept" = "application/json"
}
$ReleaseBody = @{
tag_name = $Tag
name = "GLP-1 Companion $Tag"
body = "Automated alpha release build for $Tag containing Android APK and Windows packages."
draft = $false
prerelease = $true
} | ConvertTo-Json
$ReleaseUrl = "$Server/api/v1/repos/$Owner/$Repo/releases"
try {
$ReleaseResp = Invoke-RestMethod -Uri $ReleaseUrl -Method Post -Headers $Headers -Body $ReleaseBody -ContentType "application/json"
$ReleaseId = $ReleaseResp.id
Write-Host "Created release $ReleaseId for$Tag" -ForegroundColor Green
} catch {
Write-Host "Release for $Tag may already exist, fetching existing ID..." -ForegroundColor Gray
$Existing = Invoke-RestMethod -Uri "$ReleaseUrl/tags/$Tag" -Method Get -Headers $Headers
$ReleaseId = $Existing.id
Write-Host "Using existing release ID: $ReleaseId" -ForegroundColor Green
}
$Assets = @($ApkDest, $ZipDest,$MsixDest)
foreach ($FilePath in $Assets) {
if (Test-Path $FilePath) {
$FileName = [System.IO.Path]::GetFileName($FilePath)
Write-Host "Uploading $FileName..." -ForegroundColor Cyan
& curl.exe -s -X POST "$Server/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=$FileName" `
-H "Authorization: token $ForgejoToken" `
-H "Content-Type: multipart/form-data" `
-F "attachment=@$FilePath" | Out-Null
Write-Host " -> Uploaded $FileName" -ForegroundColor Green
}
}
Write-Host "`nSuccessfully released $Tag to$Server/$Owner/$Repo/releases!" -ForegroundColor Green

52
scripts/test.ps1 Normal file
View File

@ -0,0 +1,52 @@
$Server = "https://git.kellsupport.com"
$Owner = "mtkell"
$Repo = "glp-companion"
$Tag = "v0.0.1-test"
# Token is read from the environment only — never hardcode credentials here.
$Token = $env:FORGEJO_TOKEN
if (-not $Token) {
Write-Error "FORGEJO_TOKEN environment variable is not set. Export it before running this script."
}
$DummyFile = "dummy-asset-test.txt"
"Forgejo API asset upload verification payload - $(Get-Date)" | Set-Content -Path $DummyFile -Encoding utf8
$Headers = @{
"Authorization" = "token $Token"
"Accept" = "application/json"
}
Write-Host "Checking for release for tag: $Tag..." -ForegroundColor Cyan
$ReleaseUrl = "$Server/api/v1/repos/$Owner/$Repo/releases"
try {
$Release = Invoke-RestMethod -Uri "$ReleaseUrl/tags/$Tag" -Headers $Headers -Method Get
$ReleaseId =$Release.id
Write-Host "Found existing release ID: $ReleaseId" -ForegroundColor Green
} catch {
Write-Host "Creating new draft test release for tag $Tag..." -ForegroundColor Yellow
$Release = Invoke-RestMethod -Uri $ReleaseUrl -Headers $Headers -Method Post -ContentType "application/json" -Body (@{
tag_name = $Tag
name = "Test Asset Upload Release"
body = "Testing Forgejo attachment upload endpoint."
draft = $true
prerelease = $true
} | ConvertTo-Json)
$ReleaseId =$Release.id
Write-Host "Created test release ID: $ReleaseId" -ForegroundColor Green
}
Write-Host "Uploading $DummyFile to release ID$ReleaseId via curl..." -ForegroundColor Cyan
$UploadUrl = "$Server/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=$DummyFile"
& curl.exe -s -X POST $UploadUrl `
-H "Authorization: token $Token" `
-F "attachment=@$DummyFile" `
-o "curl_response.json"
Write-Host "`nServer Response:" -ForegroundColor Gray
Get-Content -Path "curl_response.json"
Remove-Item -Path $DummyFile -Force -ErrorAction SilentlyContinue
Remove-Item -Path "curl_response.json" -Force -ErrorAction SilentlyContinue

View File

@ -86,6 +86,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true;
bool granted = true;
bool cloudReady = false;
List<HealthMetric> weights = [];
List<HealthMetric> energies = [];
int permissionRequests = 0;
@ -104,6 +105,9 @@ class _FakeHealthGateway extends IHealthGateway {
return granted;
}
@override
Future<bool> isCloudSyncReady() async => cloudReady;
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
@ -286,6 +290,32 @@ void main() {
expect(repository.entries, isEmpty);
});
test(
'native denial with a connected cloud partner still syncs partner '
'data', () async {
gateway.granted = false;
gateway.cloudReady = true;
gateway.weights = [
HealthMetric(
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
value: 182,
unit: 'lb',
),
];
await load();
final ok = await notifier().syncFromHealth();
expect(ok, isTrue);
// The native grant is still requested exactly once…
expect(gateway.permissionRequests, 1);
final state = container.read(biometricControllerProvider).valueOrNull!;
// …but the denial no longer blocks the cloud-backed sync.
expect(state.statusMessage, contains('Synced 1 weight sample(s)'));
expect(state.statusMessage, isNot(contains('denied')));
expect(repository.entries, hasLength(1));
});
test(
'imports weights and per-day active energy, then re-syncs without '
'duplicates', () async {