Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

17 changed files with 42 additions and 689 deletions

View File

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

View File

@ -2,8 +2,8 @@
## Active Target ## Active Target
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE - **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
- **Status:** Phase 7 Complete (all phases 1–7 done); alpha release pipeline landed 2026-10-18 - **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
- **Latest Addendum:** Alpha Release Pipeline & Scripting (2026-10-18) - **Latest Addendum:** Phase 4B — Private Recipe Sources & Mealie Integration (2026-10-17)
## Phase Checklist ## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
@ -197,50 +197,3 @@
- [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] 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. - [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,12 +1,3 @@
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 { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
@ -28,43 +19,36 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.companionglp.glpcompanion" 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 // API 26 (Android 8.0) floor required by the `health` plugin (Health
// Connect / HealthKit bridge), which declares minSdk 26 in its // Connect / HealthKit bridge), which declares minSdk 26 in its
// manifest; Health Connect itself only ships on Android 8.0+. // manifest; Health Connect itself only ships on Android 8.0+.
minSdk = maxOf(flutter.minSdkVersion, 26) minSdk = maxOf(flutter.minSdkVersion, 26)
targetSdk = flutter.targetSdkVersion 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 versionCode = flutter.versionCode
versionName = flutter.versionName 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 { buildTypes {
getByName("release") { release {
signingConfig = signingConfigs.getByName("release") // TODO: Add your own signing config for the release build.
isMinifyEnabled = true // Signing with the debug keys for now, so `flutter run --release` works.
isShrinkResources = true signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
} }
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
} }
} }

View File

@ -1,23 +0,0 @@
# 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,12 +4,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching <!-- Health Connect (Android 14+ / Play services): read permissions matching
the HealthGateway request set (weight, total calories burned, active the HealthGateway request set (weight, steps, total calories burned,
energy burned, blood glucose). STEPS was removed from the set: it is active energy burned, blood glucose). Health Connect federates
never fetched, and the package:health README ties step access to the Samsung Health and Pixel sensor feeds through this runtime. -->
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_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_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/> <uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/> <uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
@ -47,12 +46,6 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.companionglp.glpcompanion"/> <data android:scheme="com.companionglp.glpcompanion"/>
</intent-filter> </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> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,11 +1,5 @@
package com.companionglp.glpcompanion package com.companionglp.glpcompanion
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivity
// FlutterFragmentActivity (not the default FlutterActivity) is required by class MainActivity : FlutterActivity()
// 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,7 +7,6 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -55,30 +54,18 @@ class GoogleHealthTokens {
} }
} }
/// Google Health cloud connector: OAuth2 through Google Identity Services on /// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
/// Android (native account chooser + consent, `serverAuthCode` exchange) and /// the system browser with a loopback redirect
/// authorization-code + PKCE through the system browser with a loopback /// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
/// redirect (`http://localhost:<port>/callback`) on desktop/iOS, backed by /// transparent refresh, and normalized weight / active-energy reads from the
/// secure-storage tokens with transparent refresh, and normalized weight / /// Google Health API — the official successor to the retired Fitbit Web API
/// active-energy reads from the Google Health API — the official successor to /// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
/// the retired Fitbit Web API (shutdown 2026-10-30); Fitbit, Pixel and Wear /// Health.
/// OS data flows into Google Health.
/// ///
/// Requires `GOOGLE_HEALTH_CLIENT_ID` (a **Web application** OAuth client) /// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
/// and `GOOGLE_HEALTH_CLIENT_SECRET` in `.env` (optionally /// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
/// `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create them in a Google /// them in a Google Cloud project with the Google Health API enabled; the
/// Cloud project with the Google Health API enabled; the loopback redirect /// loopback redirect must be an Authorized redirect URI on the OAuth client.
/// 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 { class GoogleHealthConnector implements ICloudHealthConnector {
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage}) GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
: _client = client ?? http.Client(), : _client = client ?? http.Client(),
@ -151,14 +138,6 @@ class GoogleHealthConnector implements ICloudHealthConnector {
return false; 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 verifier = _randomUrlSafe(64);
final authorizeUrl = Uri.parse( final authorizeUrl = Uri.parse(
'$_authorizeEndpoint?response_type=code' '$_authorizeEndpoint?response_type=code'
@ -200,73 +179,9 @@ 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 @override
Future<void> disconnect() async { Future<void> disconnect() async {
await _storage.delete(key: tokenStorageKey); 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 @override

View File

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

View File

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

View File

@ -203,12 +203,6 @@ class IntegrationsController extends Notifier<IntegrationsState> {
isRequestingPermissions: false, isRequestingPermissions: false,
errorMessage: granted errorMessage: granted
? null ? null
: 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 ' : 'Health permissions were denied. Enable them in your device '
'settings to sync biometrics.', 'settings to sync biometrics.',
); );

View File

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

View File

@ -49,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -193,14 +185,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
console:
dependency: transitive
description:
name: console
sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a
url: "https://pub.dev"
source: hosted
version: "4.1.0"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -453,14 +437,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.1" 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: glob:
dependency: transitive dependency: transitive
description: description:
@ -477,54 +453,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.8.1" 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: gotrue:
dependency: transitive dependency: transitive
description: description:
@ -589,14 +517,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
intl: intl:
dependency: transitive dependency: transitive
description: description:
@ -733,14 +653,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.3" 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: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:
@ -877,14 +789,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.3" version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
postgrest: postgrest:
dependency: transitive dependency: transitive
description: description:

View File

@ -55,7 +55,6 @@ dependencies:
sqlite3: ^3.4.0 sqlite3: ^3.4.0
# Scheduling support for flutter_local_notifications (zonedSchedule). # Scheduling support for flutter_local_notifications (zonedSchedule).
timezone: ^0.9.4 timezone: ^0.9.4
google_sign_in: 6.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -68,17 +67,6 @@ dev_dependencies:
# Riverpod 3 migration. # Riverpod 3 migration.
build_runner: ^2.16.0 build_runner: ^2.16.0
drift_dev: ^2.35.1 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 # 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 (the sqlcipher_flutter_libs plugin is retired on sqlite3 3.x).

View File

@ -1,92 +0,0 @@
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

View File

@ -1,52 +0,0 @@
$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,7 +86,6 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
class _FakeHealthGateway extends IHealthGateway { class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true; bool nativeSupported = true;
bool granted = true; bool granted = true;
bool cloudReady = false;
List<HealthMetric> weights = []; List<HealthMetric> weights = [];
List<HealthMetric> energies = []; List<HealthMetric> energies = [];
int permissionRequests = 0; int permissionRequests = 0;
@ -105,9 +104,6 @@ class _FakeHealthGateway extends IHealthGateway {
return granted; return granted;
} }
@override
Future<bool> isCloudSyncReady() async => cloudReady;
@override @override
Future<List<HealthMetric>> fetchWeightHistory({ Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start, required DateTime start,
@ -290,32 +286,6 @@ void main() {
expect(repository.entries, isEmpty); 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( test(
'imports weights and per-day active energy, then re-syncs without ' 'imports weights and per-day active energy, then re-syncs without '
'duplicates', () async { 'duplicates', () async {