diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b89254 --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# ------------------------------------------------------------------------------ +# Build metadata +# ------------------------------------------------------------------------------ +FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NUMBER=1 + +# App identifiers +ANDROID_APPLICATION_ID=org.stormlightlabs.lazurite +IOS_BUNDLE_ID=org.stormlightlabs.lazurite + +# ------------------------------------------------------------------------------ +# Android signing (Play + side channels) +# ------------------------------------------------------------------------------ +ANDROID_KEYSTORE_PATH=/absolute/path/upload-keystore.jks +ANDROID_KEYSTORE_PASSWORD= +ANDROID_KEY_ALIAS=upload +ANDROID_KEY_PASSWORD= + +# Optional: if using android/key.properties templating +ANDROID_KEY_PROPERTIES_PATH=android/key.properties + +# ------------------------------------------------------------------------------ +# Google Play +# ------------------------------------------------------------------------------ +PLAY_PACKAGE_NAME=org.stormlightlabs.lazurite +PLAY_TRACK=internal +PLAY_CHANGES_NOT_SENT_FOR_REVIEW=true + +# ------------------------------------------------------------------------------ +# App Store Connect / TestFlight +# ------------------------------------------------------------------------------ +ASC_APPLE_ID= +ASC_TEAM_ID= +ASC_BUNDLE_ID=org.stormlightlabs.lazurite +ASC_KEY_ID= +ASC_ISSUER_ID= +ASC_API_PRIVATE_KEY_PATH=/absolute/path/AuthKey_ABC123XYZ.p8 + +# ------------------------------------------------------------------------------ +# AltStore PAL / Classic +# ------------------------------------------------------------------------------ +ALTSTORE_DEVELOPER_ID= +ALTSTORE_CONTACT_EMAIL= +ALTSTORE_MARKETPLACE_TOKEN= +ALTSTORE_APPLE_MARKETPLACE_ID= +ALTSTORE_SOURCE_JSON_URL=https://example.com/altstore/source.json +ALTSTORE_ADP_HOST_ROOT=https://example.com/lazurite/adp/ +ALTSTORE_CLASSIC_IPA_URL=https://example.com/lazurite/lazurite.ipa + +# ------------------------------------------------------------------------------ +# Firebase / FCM +# ------------------------------------------------------------------------------ +FIREBASE_PROJECT_ID= +FIREBASE_ANDROID_APP_ID= +FIREBASE_IOS_APP_ID= +FIREBASE_ANDROID_PACKAGE_NAME=org.stormlightlabs.lazurite +FIREBASE_IOS_BUNDLE_ID=org.stormlightlabs.lazurite + +# File locations in this repo +FIREBASE_ANDROID_CONFIG_PATH=android/app/google-services.json +FIREBASE_IOS_CONFIG_PATH=ios/Runner/GoogleService-Info.plist + +# APNs auth for iOS push via Firebase Cloud Messaging +FIREBASE_APNS_KEY_ID= +FIREBASE_APNS_TEAM_ID= +FIREBASE_APNS_P8_PATH=/absolute/path/AuthKey_APNS.p8 + +# ------------------------------------------------------------------------------ +# GitHub release automation +# ------------------------------------------------------------------------------ +GITHUB_REPOSITORY=owner/repo +GITHUB_RELEASE_TAG=v${FLUTTER_BUILD_NAME} +GITHUB_RELEASE_NOTES_MODE=generate + +# Optional: API token for release automation jobs +GITHUB_TOKEN= diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ab0ff4e..0df05c6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { @@ -13,6 +14,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -41,3 +43,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..e98b5f1 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "251668382991", + "project_id": "lazurite-push", + "storage_bucket": "lazurite-push.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:251668382991:android:621a1c3c3e6afc1f66ea7a", + "android_client_info": { + "package_name": "org.stormlightlabs.lazurite" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyD0AcVccAcTyBAFqhfahqiPS8CvUHKI7Kg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..51a76cd 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.3" apply false } include(":app") diff --git a/docs/release.md b/docs/release.md index 853784a..383fea6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,89 +1,272 @@ --- -title: Release Audit - Apple App Store + Google Play -updated: 2026-04-15 -scope: Repository audit for likely store submission blockers or high-risk policy issues. +title: Release and Distribution Guide +updated: 2026-05-02 --- -## Sources - -- Apple App Review Guidelines: - - 1.2 User-Generated Content requirements - - 5.1.1 Data Collection and Storage (Privacy Policies) - - 4.8 Login Services (third-party login exception for service clients) -- Google Play User Data policy: - - Privacy Policy requirement - - Account deletion requirement (if account creation is supported) -- Google Play Developer Programme Policy (UGC section): - - UGC terms acceptance + moderation expectations - -## Findings - -### No in-app Privacy Policy link/text - -- Policy mapping: - - Apple 5.1.1(i): privacy policy must be linked in App Store Connect and in-app in an easily accessible manner. - - Google Play User Data: privacy policy link/text must exist in Play Console and in-app. -- Evidence in app: - - Login has no privacy/terms surface: `lib/features/auth/presentation/login_screen.dart` (see UI around lines 54-205). - - Settings has no legal/privacy entry: `lib/features/settings/presentation/settings_screen.dart` (lines 69-151). - - About page has external links and email only, no privacy policy link: `lib/features/settings/presentation/about_screen.dart` (lines 8-97). - - Existing backlog confirms missing policy: `docs/TODO.md` (lines 62-65). -- Impact: High probability of rejection by both stores until fixed. - -### High Risk (Google Play UGC): No explicit Terms/User Policy acceptance before posting UGC - -- Policy mapping: - - Google Play UGC policy requires robust moderation, including requiring acceptance of Terms of Use and/or user policy before users create/upload UGC. -- Evidence in app: - - Compose allows direct posting with no terms acceptance gate: `lib/features/compose/presentation/compose_screen.dart` (lines 567-588, especially Post action at 584-587). - - No in-app terms/user policy screen found in `lib/`. -- Impact: Elevated Play policy risk for social/UGC apps. - -### Moderate Risk (Apple UGC 1.2): Posting-side objectionable-content controls are not explicit - -- Policy mapping: - - Apple 1.2 says UGC/social apps should include a method for filtering objectionable material from being posted. -- Evidence: - - Reporting/blocking exists (good): - - `lib/features/profile/presentation/widgets/profile_action_buttons.dart` (Report/Block UI around lines 85-140). - - `lib/features/profile/presentation/widgets/report_dialog.dart` (report flow lines 10-220). - - Moderation controls exist for viewed content (good): `lib/features/settings/presentation/settings_screen.dart` (Moderation section lines 75-77). - - No explicit compose-time objectionable-content filter is visible in compose flow. -- Impact: Could pass if platform-side moderation is accepted by review, but still a non-trivial risk without clear reviewer notes. - -### Reviewer-access risk for App Store - -- Apple "Before You Submit" requires full reviewer access (demo account or demo mode for account-based features). -- App is account-based and login-gated; no repo evidence of dedicated reviewer/demo path. -- Impact: Common review delay/rejection if review notes do not include working credentials. - - Create a real Bluesky account for reviewers, populate it with sample content, and - provide the credentials in App Store Connect / Play Console. - -## OK - -- In-app report mechanism exists for posts/accounts: - - `lib/features/profile/presentation/widgets/report_dialog.dart`. -- Block/mute actions exist: - - `lib/features/profile/presentation/widgets/profile_action_buttons.dart`. -- User-reachable contact info exists: - - Email link in About: `lib/features/settings/presentation/about_screen.dart` line 11 + UI lines 90-93. -- Sign in with Apple requirement appears likely exempt: - - App behaves as a client for a specific third-party service (Bluesky), matching Apple 4.8 exception language. - -## Fixes (In Priority Order) - -- [x] Add a dedicated Legal screen and surface: - - [x] Privacy Policy (in-app link + readable text summary) - - [x] Terms of Use / User Policy - - [x] Reachable from login and settings/about -- [ ] Add UGC policy acceptance flow before first create/upload action (compose, media upload, messages if applicable). -- [ ] Document moderation operations in policy/reviewer notes: - - [ ] How reports are handled and SLA - - [ ] What objectionable content rules apply -- [ ] Replace placeholder identifiers and release signing setup: - - [ ] Android `applicationId` - - [ ] Android release signing config (non-debug) - - [ ] iOS bundle IDs + distribution signing -- [ ] Prepare App Store review notes with working reviewer credentials/demo path. -- [ ] Verify account deletion obligations: - - [ ] If any account creation is enabled in-app, add in-app deletion entry point per Apple/Google rules. +## Shared Release Baseline + +1. Pin and verify toolchain. + - `flutter --version` + - `dart --version` +2. Set version in `pubspec.yaml` and/or pass build flags. + - `--build-name` => human version (for example `1.4.0`) + - `--build-number` => monotonically increasing integer +3. Run release gates. + - `flutter pub get` + - `flutter analyze` + - `gtimeout 1200s flutter test --reporter=failures-only` +4. Tag immutable source. + - `git tag vX.Y.Z` +5. Build store artifacts from that exact tag/commit. + +## Environment Variables + +Use the root `.env.example` as the canonical variable list. Keep real values in untracked secrets (`.env.local`, CI secrets manager, etc.). + +## Google Play (Android) + +### Build + +1. Configure real release signing (upload key), not debug signing. +2. Build AAB (preferred by Google Play): + + ```bash + flutter build appbundle --release \ + --build-name "$FLUTTER_BUILD_NAME" \ + --build-number "$FLUTTER_BUILD_NUMBER" + ``` + +3. Artifact: `build/app/outputs/bundle/release/app-release.aab` + +### Deploy + +1. Enroll in Play App Signing. +2. Upload `app-release.aab` to Internal testing first. +3. Promote to closed/open/production after validation. + +### Notes + +- If you need the same signing key across multiple stores, provide your own app signing key when configuring Play App Signing. +- For non-Play Android channels, ship signed APKs (Play consumes AAB; side channels consume APK). + +## Apple App Store (iOS) + +### Build + +1. Use an explicit App ID + matching bundle ID. +2. Build signed IPA: + + ```bash + flutter build ipa --release \ + --build-name "$FLUTTER_BUILD_NAME" \ + --build-number "$FLUTTER_BUILD_NUMBER" + ``` + +3. Artifact: `build/ios/ipa/*.ipa` + +### Deploy + +1. Upload using Xcode or Transporter to App Store Connect. +2. Wait for processing. +3. Ship through TestFlight (internal/external) first. +4. Submit selected build for App Review. + +### Notes + +- App Store Connect associates build using bundle ID + version + build string. +- As of 2026, Apple requires Xcode 14+ for uploads. + +## AltStore.io + +AltStore distribution has two distinct paths. + +### AltStore PAL (EU marketplace path) + +#### Build/Package + +1. Build iOS release (`flutter build ipa --release`). +2. Submit via App Store Connect with Notarization (or App Store approval, which also results in notarization). +3. Download the Alternative Distribution Package (ADP). +4. Host ADP exactly as-delivered; preserve directory hierarchy and do not modify `manifest.json`. + +#### Deploy + +1. Accept Apple Alternative EU Terms Addendum. +2. Register Developer ID with AltStore PAL API. +3. Add returned marketplace token in App Store Connect Integrations. +4. Publish a Source JSON with required app/version metadata. + +### AltStore Classic (sideloaded IPA path) + +#### Build + +1. Build/sign IPA (`flutter build ipa --release`). +2. Host IPA at stable HTTPS URL. + +#### Deploy + +1. Publish/update Source JSON. +2. Keep newest entry first in `versions` array. +3. Ensure each release updates `version` (`CFBundleShortVersionString`) and/or `buildVersion` (`CFBundleVersion`). +4. Include accurate `downloadURL`, `size`, and optional `minOSVersion` / `maxOSVersion`. + +### Notes + +- AltStore determines latest release by `versions` ordering, not dates. +- AltStore checks declared app permissions/entitlements against downloaded app package. + +## F-Droid + +### Build Strategy + +1. Decide target: + - Main F-Droid repo, or + - Your own F-Droid-compatible repo. +2. For main F-Droid repo, create an `fdroid` flavor without proprietary SDKs (Firebase/Crashlytics/Play Services tracking dependencies are not allowed in main repo). +3. Build signed deterministic APK for that flavor. + +Recommended flavor command pattern: + +```bash +flutter build apk --release \ + --flavor fdroid \ + --target lib/main_fdroid.dart \ + --build-name "$FLUTTER_BUILD_NAME" \ + --build-number "$FLUTTER_BUILD_NUMBER" +``` + +### Deploy + +1. Prepare fdroiddata metadata and submit inclusion proposal (prefer metadata merge request path). +2. Keep upstream releases tagged. +3. Aim for reproducible builds from day one (strongly recommended by F-Droid, harder to retrofit later). + +### Notes + +- If Firebase push is required in Play/App Store builds, maintain a clean non-proprietary F-Droid flavor where push is disabled or replaced. + +## Obtainium (Android direct update channel) + +### Build + +1. Produce signed APK artifacts for direct install. +2. Prefer a stable, machine-discoverable release URL source (typically GitHub Releases). + +### Deploy + +1. Publish release where source exposes: + - version identifier + - at least one APK download URL +2. If multiple APK variants exist, keep filenames explicit (`arm64-v8a`, `universal`, etc.) so users can filter reliably. + +### Notes + +- Obtainium supports GitHub, GitLab, F-Droid repos, direct APK links, and HTML fallback. +- Cross-store signing matters: updating from F-Droid-signed to non-F-Droid-signed builds may fail due to signature mismatch. + +## GitHub Releases + +### Build + +1. Build release artifacts from tagged commit (`vX.Y.Z`). +2. Generate checksums for all distributables. + +Example: + +```bash +shasum -a 256 build/app/outputs/flutter-apk/*.apk build/ios/ipa/*.ipa > checksums.txt +``` + +### Deploy + +1. Create release from tag. +2. Attach binaries (`.aab`, `.apk`, `.ipa`, `checksums.txt`, optional symbols/maps). +3. Use generated release notes, then curate manually. + +CLI example: + +```bash +gh release create "v${FLUTTER_BUILD_NAME}" \ + --generate-notes \ + build/app/outputs/bundle/release/app-release.aab \ + build/app/outputs/flutter-apk/*.apk \ + build/ios/ipa/*.ipa \ + checksums.txt +``` + +### Hardening (Recommended) + +- Add artifact attestations in GitHub Actions for build provenance. +- Keep each release asset < 2 GiB. + +## Firebase Push Notifications (iOS + Android) + +### 1) Firebase Project and App Registration + +1. Install CLI tooling. + - `firebase login` + - `dart pub global activate flutterfire_cli` +2. Run: + + ```bash + flutterfire configure + ``` + +3. Commit generated `lib/firebase_options.dart`. + +### 2) Platform Config Files + +1. Android: place `google-services.json` at `android/app/google-services.json`. +2. iOS: place `GoogleService-Info.plist` at `ios/Runner/GoogleService-Info.plist` and include it in Runner target. + +### 3) Android Gradle Wiring + +1. Add Google services Gradle plugin in project/plugin management. +2. Apply `com.google.gms.google-services` in app module. + +### 4) Apple Push Prerequisites + +1. In Xcode, enable `Push Notifications` capability. +2. In Xcode Background Modes, enable: + - `Background fetch` + - `Remote notifications` +3. Upload APNs auth key (`.p8`, Key ID, Team ID) in Firebase Console > Project Settings > Cloud Messaging. + +### 5) App Initialization and Runtime + +1. Initialize with generated options: + + ```dart + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + ``` + +2. Request user notification permission (iOS) before expecting token delivery. +3. Register and sync FCM token with backend; rotate on refresh. + +### 6) Channel-Specific Caveat + +- F-Droid mainline build should not include proprietary Firebase dependencies. +Keep push-enabled binaries for Play/App Store/AltStore/Obtainium, and maintain a non-Firebase F-Droid flavor. + +## Primary References + +- Flutter Android release: +- Flutter iOS release: +- Android signing + Play App Signing: +- Play App Signing help: +- App Store Connect uploads: +- Apple explicit App ID / bundle ID requirements: and +- AltStore PAL distribution: +- AltStore source format: +- AltStore updates/version ordering: +- F-Droid inclusion policy: +- F-Droid inclusion workflow: +- F-Droid reproducible builds: +- Obtainium tracking/source behavior: and +- GitHub releases: +- GitHub release notes automation: +- GitHub build provenance (artifact attestations): +- GitHub release asset limits: +- Firebase Flutter setup: +- Firebase FCM Flutter setup: +- Firebase Android config (`google-services.json` + plugin): diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fb383a9..9a3a1a7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -134,6 +135,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -487,6 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -674,6 +677,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -701,6 +705,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..d06a413 --- /dev/null +++ b/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyA3x2u6DzV_OqMpPPoDnq2ZmF7z0fjl4jk + GCM_SENDER_ID + 251668382991 + PLIST_VERSION + 1 + BUNDLE_ID + org.stormlightlabs.lazurite + PROJECT_ID + lazurite-push + STORAGE_BUCKET + lazurite-push.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:251668382991:ios:327b0e0f48ac3e7366ea7a + + \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 0a7e0b2..bf8ffe1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -71,6 +71,7 @@ processing fetch + remote-notification UILaunchStoryboardName LaunchScreen diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/lib/core/scheduler/post_scheduler.dart b/lib/core/scheduler/post_scheduler.dart index f64be65..3204da1 100644 --- a/lib/core/scheduler/post_scheduler.dart +++ b/lib/core/scheduler/post_scheduler.dart @@ -253,7 +253,7 @@ class PostScheduler { /// Initialises WorkManager. Call once from main() before runApp(). static Future initialize() async { - await Workmanager().initialize(callbackDispatcher, isInDebugMode: false); + await Workmanager().initialize(callbackDispatcher); } /// Registers a one-off background task to fire at [scheduledAt]. diff --git a/lib/features/notifications/background/notification_background_worker.dart b/lib/features/notifications/background/notification_background_worker.dart index b94fd34..3c3076e 100644 --- a/lib/features/notifications/background/notification_background_worker.dart +++ b/lib/features/notifications/background/notification_background_worker.dart @@ -72,7 +72,7 @@ class NotificationBackgroundScheduler { notificationReconcileUniqueName, notificationReconcileTaskName, frequency: const Duration(minutes: 15), - existingWorkPolicy: ExistingWorkPolicy.keep, + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, constraints: Constraints(networkType: NetworkType.connected), ); return; diff --git a/lib/features/notifications/data/firebase_push_token_provider.dart b/lib/features/notifications/data/firebase_push_token_provider.dart index 6cbb4ff..8d71b59 100644 --- a/lib/features/notifications/data/firebase_push_token_provider.dart +++ b/lib/features/notifications/data/firebase_push_token_provider.dart @@ -30,6 +30,14 @@ class FirebasePushTokenProvider implements PushTokenProvider { _messaging ??= FirebaseMessaging.instance; final messaging = _messaging!; + final notificationSettings = await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + ); + log.i('Notification permission status: ${notificationSettings.authorizationStatus.name}'); + await messaging.setAutoInitEnabled(true); _refreshSubscription = messaging.onTokenRefresh.listen( diff --git a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart index e64f938..387313a 100644 --- a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart +++ b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; /// Ensures tapping outside a focused [EditableText] dismisses keyboard focus @@ -12,7 +14,19 @@ class GlobalTapOutsideUnfocus extends StatelessWidget { actions: >{ EditableTextTapOutsideIntent: CallbackAction( onInvoke: (intent) { - intent.focusNode.unfocus(); + // Preserve Flutter's default down-event behavior on touch so overlay + // interactions (like typeahead suggestion taps) are not interrupted. + if (intent.pointerDownEvent.kind != ui.PointerDeviceKind.touch) { + intent.focusNode.unfocus(); + } + return null; + }, + ), + EditableTextTapUpOutsideIntent: CallbackAction( + onInvoke: (intent) { + if (intent.pointerUpEvent.kind == ui.PointerDeviceKind.touch) { + intent.focusNode.unfocus(); + } return null; }, ), diff --git a/pubspec.lock b/pubspec.lock index f7eb220..5fe937e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1809,10 +1809,34 @@ packages: dependency: "direct main" description: name: workmanager - sha256: ed13530cccd28c5c9959ad42d657cd0666274ca74c56dea0ca183ddd527d3a00 + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" url: "https://pub.dev" source: hosted - version: "0.5.2" + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index accc3a3..2bb536d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: image_picker: ^1.1.2 cross_file: ^0.3.4+2 characters: ^1.4.0 - workmanager: ^0.5.2 + workmanager: ^0.9.0+3 plugin_platform_interface: ^2.1.8 url_launcher_platform_interface: ^2.3.2 connectivity_plus: ^7.0.0 diff --git a/test/features/typeahead/presentation/typeahead_text_field_test.dart b/test/features/typeahead/presentation/typeahead_text_field_test.dart index 6abe180..bb65beb 100644 --- a/test/features/typeahead/presentation/typeahead_text_field_test.dart +++ b/test/features/typeahead/presentation/typeahead_text_field_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; +import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart'; void main() { group('TypeaheadTextField', () { @@ -55,6 +56,46 @@ void main() { expect(find.text('Alice'), findsNothing); }); + testWidgets('tap result still selects when wrapped in GlobalTapOutsideUnfocus', (tester) async { + final controller = TextEditingController(); + TypeaheadResult? selected; + + final repository = _FakeTypeaheadRepository( + searchHandler: ({required String query, int limit = 10}) async { + return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')]; + }, + ); + + await tester.pumpWidget( + MaterialApp( + home: GlobalTapOutsideUnfocus( + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: TypeaheadTextField( + controller: controller, + repository: repository, + onSelected: (result) => selected = result, + debounceMs: 1, + minChars: 2, + ), + ), + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'alice'); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + + expect(selected, isNotNull); + expect(selected!.did, 'did:plc:alice'); + }); + testWidgets('tap outside dismisses overlay', (tester) async { final controller = TextEditingController(); final focusNode = FocusNode(); diff --git a/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart b/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart index 4cecf48..1cd99a4 100644 --- a/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart +++ b/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart @@ -31,4 +31,37 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasFocus, isFalse); }); + + testWidgets('touch down outside does not unfocus until touch up', (tester) async { + final focusNode = FocusNode(debugLabel: 'global-focus-test-up'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: GlobalTapOutsideUnfocus( + child: Scaffold( + body: Column( + children: [ + TextField(focusNode: focusNode), + const SizedBox(height: 200), + const SizedBox(width: 120, height: 40, child: ColoredBox(color: Colors.red)), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + + final gesture = await tester.startGesture(const Offset(20, 260)); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + }); }