From 4f09b4e56a52b7435e41530aba1297924d04b377 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 01:04:23 +0800 Subject: [PATCH 1/2] feat: add fcm push and support google analytics for apps --- .github/workflows/build.yaml | 5 + .github/workflows/test.yaml | 25 + .gitignore | 5 + .../Sources/RxCodeSync/Protocol/Payload.swift | 17 + .../Tests/RxCodeSyncTests/PayloadTests.swift | 18 + RxCode.xcodeproj/project.pbxproj | 41 ++ .../xcshareddata/swiftpm/Package.resolved | 128 ++++- RxCode/App/FirebaseBootstrap.swift | 19 + RxCode/App/RxCodeApp.swift | 1 + .../MobileSyncService+EventDispatch.swift | 29 + .../MobileSyncService+LiveActivity.swift | 2 +- RxCode/Services/MobileSyncService.swift | 156 +++++- RxCode/Views/Settings/MobileSettingsTab.swift | 7 +- RxCodeAndroid/app/build.gradle.kts | 9 + .../app/src/main/AndroidManifest.xml | 9 + .../java/app/rxlab/rxcode/MainActivity.kt | 16 + .../app/rxlab/rxcode/RxCodeApplication.kt | 9 + .../java/app/rxlab/rxcode/proto/Payload.kt | 12 + .../app/rxlab/rxcode/push/EncryptedAlert.kt | 20 + .../app/rxlab/rxcode/push/FcmTokenReporter.kt | 49 ++ .../push/RxCodeFirebaseMessagingService.kt | 126 +++++ .../app/rxlab/rxcode/state/MobileAppState.kt | 44 +- .../ui/briefing/BriefingDetailScreen.kt | 24 +- .../rxlab/rxcode/ui/briefing/BriefingPane.kt | 10 +- .../rxlab/rxcode/ui/projects/ProjectsPane.kt | 37 +- .../rxcode/ui/sessions/SessionsScreen.kt | 18 +- .../rxlab/rxcode/ui/sheets/NewThreadSheet.kt | 518 ++++++++++++++---- RxCodeAndroid/build.gradle.kts | 2 + RxCodeAndroid/gradle/libs.versions.toml | 9 + RxCodeMobile/FirebaseBootstrap.swift | 19 + RxCodeMobile/RxCodeMobileApp.swift | 1 + ci_scripts/ci_post_clone.sh | 4 + relay-server/README.md | 25 +- relay-server/fcm.go | 265 +++++++++ relay-server/go.mod | 2 +- relay-server/health.go | 5 +- relay-server/k8s/README.md | 17 +- relay-server/k8s/configmap.yaml | 4 + relay-server/main.go | 26 +- relay-server/push.go | 42 +- scripts/ci/write-firebase-config.sh | 32 ++ 41 files changed, 1611 insertions(+), 196 deletions(-) create mode 100644 RxCode/App/FirebaseBootstrap.swift create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/EncryptedAlert.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/FcmTokenReporter.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt create mode 100644 RxCodeMobile/FirebaseBootstrap.swift create mode 100644 relay-server/fcm.go create mode 100755 scripts/ci/write-firebase-config.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 755a832d..d3e2d098 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -46,6 +46,11 @@ jobs: p12-file-base64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} p12-password: ${{ secrets.P12_PASSWORD }} + - name: Write Firebase config + env: + FIREBASE_MACOS_B64: ${{ secrets.FIREBASE_MACOS_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Bump version (release only) if: github.event_name == 'release' run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cd510d3b..b61b5e30 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -62,6 +62,11 @@ jobs: - name: Install xcpretty run: gem install xcpretty + - name: Write Firebase config + env: + FIREBASE_MACOS_B64: ${{ secrets.FIREBASE_MACOS_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Run Xcode tests run: | set -o pipefail && xcodebuild \ @@ -90,6 +95,11 @@ jobs: - name: Install xcpretty run: gem install xcpretty + - name: Write Firebase config + env: + FIREBASE_MACOS_B64: ${{ secrets.FIREBASE_MACOS_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Build (no signing) run: | set -o pipefail && xcodebuild \ @@ -116,6 +126,11 @@ jobs: - name: Install xcpretty run: gem install xcpretty + - name: Write Firebase config + env: + FIREBASE_IOS_B64: ${{ secrets.FIREBASE_IOS_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Build mobile app (ad-hoc signing) run: | # The iOS app embeds extensions (notification service, widget), so @@ -163,6 +178,11 @@ jobs: - name: Install xcpretty run: gem install xcpretty + - name: Write Firebase config + env: + FIREBASE_IOS_B64: ${{ secrets.FIREBASE_IOS_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Resolve simulator id: sim run: | @@ -233,6 +253,11 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Write Firebase config + env: + FIREBASE_ANDROID_B64: ${{ secrets.FIREBASE_ANDROID_B64 }} + run: ./scripts/ci/write-firebase-config.sh + - name: Assemble debug APK working-directory: RxCodeAndroid run: ./gradlew assembleDebug --stacktrace diff --git a/.gitignore b/.gitignore index 6cd13332..98cddb79 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ relay-server/.env.* relay-server/*.p8 relay-server/k8s/secrets.yaml .playwright-mcp + +# Firebase config files (decoded from CI secrets, never committed) +RxCode/GoogleService-Info.plist +RxCodeMobile/GoogleService-Info.plist +RxCodeAndroid/app/google-services.json diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 15efaec7..31d12578 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -11,6 +11,7 @@ public enum Payload: Sendable { case pairAck(PairAckPayload) case unpair(UnpairPayload) case apnsToken(APNsTokenPayload) + case pushToken(PushTokenPayload) case liveActivityToken(LiveActivityTokenPayload) case requestSnapshot(RequestSnapshotPayload) case snapshot(SnapshotPayload) @@ -75,6 +76,7 @@ public extension Payload { case .pairAck: return "pair_ack" case .unpair: return "unpair" case .apnsToken: return "apns_token" + case .pushToken: return "push_token" case .liveActivityToken: return "live_activity_token" case .requestSnapshot: return "request_snapshot" case .snapshot: return "snapshot" @@ -184,6 +186,18 @@ public struct APNsTokenPayload: Codable, Sendable { } } +public struct PushTokenPayload: Codable, Sendable { + public let provider: String + public let token: String + public let environment: String? + + public init(provider: String, token: String, environment: String? = nil) { + self.provider = provider + self.token = token + self.environment = environment + } +} + /// Mobile → desktop: ActivityKit push tokens for the job Live Activity. A /// single payload reports either the device-wide push-to-start token, a /// per-activity update token, or both. The desktop stores them per paired @@ -675,6 +689,7 @@ extension Payload: Codable { case pairAck = "pair_ack" case unpair case apnsToken = "apns_token" + case pushToken = "push_token" case liveActivityToken = "live_activity_token" case requestSnapshot = "request_snapshot" case snapshot @@ -743,6 +758,7 @@ extension Payload: Codable { case .pairAck: self = .pairAck(try container.decode(PairAckPayload.self, forKey: .data)) case .unpair: self = .unpair(try container.decode(UnpairPayload.self, forKey: .data)) case .apnsToken: self = .apnsToken(try container.decode(APNsTokenPayload.self, forKey: .data)) + case .pushToken: self = .pushToken(try container.decode(PushTokenPayload.self, forKey: .data)) case .liveActivityToken: self = .liveActivityToken(try container.decode(LiveActivityTokenPayload.self, forKey: .data)) case .requestSnapshot: self = .requestSnapshot(try container.decode(RequestSnapshotPayload.self, forKey: .data)) case .snapshot: self = .snapshot(try container.decode(SnapshotPayload.self, forKey: .data)) @@ -807,6 +823,7 @@ extension Payload: Codable { case .pairAck(let p): try container.encode(TypeKey.pairAck.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unpair(let p): try container.encode(TypeKey.unpair.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .apnsToken(let p): try container.encode(TypeKey.apnsToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .pushToken(let p): try container.encode(TypeKey.pushToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .liveActivityToken(let p): try container.encode(TypeKey.liveActivityToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .requestSnapshot(let p): try container.encode(TypeKey.requestSnapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .snapshot(let p): try container.encode(TypeKey.snapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 082f21bb..8c4f0d3f 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -76,6 +76,24 @@ struct PayloadTests { #expect(request.apnsEnvironment == "sandbox") } + @Test("push token carries provider and token") + func pushTokenCarriesProviderAndToken() throws { + let payload = Payload.pushToken( + PushTokenPayload(provider: "fcm", token: "fcm-token") + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .pushToken(let token) = decoded else { + Issue.record("Expected push token payload") + return + } + + #expect(token.provider == "fcm") + #expect(token.token == "fcm-token") + #expect(token.environment == nil) + } + @Test("snapshot carries briefing and settings data") func snapshotCarriesBriefingAndSettingsData() throws { let projectId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 28db4bbe..871098d6 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -42,6 +42,10 @@ E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; }; E6D001072FA0000100000001 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001062FA0000100000001 /* RxCodeSync */; }; + FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000010000000000000001 /* FirebaseAnalytics */; }; + FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000020000000000000001 /* FirebaseCrashlytics */; }; + FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000050000000000000001 /* FirebaseAnalytics */; }; + FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000060000000000000001 /* FirebaseCrashlytics */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -244,6 +248,8 @@ DF230BA32FBC73BA008929A6 /* RxCodeSync in Frameworks */, DF230BA12FBC73BA008929A6 /* RxCodeCore in Frameworks */, DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */, + FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */, + FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -283,6 +289,8 @@ E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, + FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */, + FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -459,6 +467,8 @@ DF230B9E2FBC73BA008929A6 /* RxCodeChatKit */, DF230BA02FBC73BA008929A6 /* RxCodeCore */, DF230BA22FBC73BA008929A6 /* RxCodeSync */, + FB0000050000000000000001 /* FirebaseAnalytics */, + FB0000060000000000000001 /* FirebaseCrashlytics */, ); productName = RxCodeMobile; productReference = DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */; @@ -560,6 +570,8 @@ E6D001022FA0000100000001 /* RxCodeChatKit */, E6D001062FA0000100000001 /* RxCodeSync */, DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, + FB0000010000000000000001 /* FirebaseAnalytics */, + FB0000020000000000000001 /* FirebaseCrashlytics */, ); productName = RxCode; productReference = E67335382F7356F600FD26C7 /* RxCode.app */; @@ -614,6 +626,7 @@ E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */, DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */, DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */, + FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -1466,6 +1479,14 @@ minimumVersion = 2.0.0; }; }; + FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.4.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1547,6 +1568,26 @@ package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; productName = RxCodeSync; }; + FB0000010000000000000001 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + FB0000020000000000000001 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + FB0000050000000000000001 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + FB0000060000000000000001 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E67335302F7356F600FD26C7 /* Project object */; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a01ad1a..6d011f92 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,114 @@ { - "originHash" : "cd0f1869aec772d2717b6f2ddf4927d69ca9a55e0482f7b012a30bb999a17484", + "originHash" : "ad33b5f1440a056884e1b20c3f3f5d42f2cfe1032060bcf21e0a004535c018c2", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", + "version" : "11.15.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version" : "2.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", + "version" : "11.15.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", + "version" : "4.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", @@ -10,6 +118,15 @@ "version" : "6.0.1" } }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -55,6 +172,15 @@ "version" : "2.4.1" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", + "version" : "1.38.0" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl", diff --git a/RxCode/App/FirebaseBootstrap.swift b/RxCode/App/FirebaseBootstrap.swift new file mode 100644 index 00000000..9e499bca --- /dev/null +++ b/RxCode/App/FirebaseBootstrap.swift @@ -0,0 +1,19 @@ +import FirebaseCore +import FirebaseCrashlytics +import Foundation +import os.log + +enum FirebaseBootstrap { + private static let logger = Logger(subsystem: "com.idealapp.RxCode", category: "Firebase") + + static func configure() { + guard FirebaseApp.app() == nil else { return } + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") + return + } + FirebaseApp.configure() + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + logger.info("Firebase configured") + } +} diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 34107aa6..d8a02848 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -39,6 +39,7 @@ struct RxCodeApp: App { private let updateService = UpdateService.shared init() { + FirebaseBootstrap.configure() try? Tips.configure([ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault), diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index 007a41bc..c2659051 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -97,6 +97,8 @@ extension MobileSyncService { case .apnsToken(let t): if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { logger.info("[APNs] token received mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") + pairedDevices[idx].pushProvider = "apns" + pairedDevices[idx].pushToken = t.tokenHex pairedDevices[idx].apnsToken = t.tokenHex pairedDevices[idx].apnsEnvironment = Self.normalizedAPNSEnvironment(t.environment) pairedDevices[idx].lastSeen = .now @@ -109,6 +111,33 @@ extension MobileSyncService { } else { logger.warning("[APNs] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") } + case .pushToken(let t): + let provider = t.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !provider.isEmpty, !t.token.isEmpty else { + logger.warning("[Push] ignored empty token provider=\(provider, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + return + } + if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { + logger.info("[Push] token received provider=\(provider, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.token.prefix(12)), privacy: .public)") + pairedDevices[idx].pushProvider = provider + pairedDevices[idx].pushToken = t.token + if provider == "apns" { + pairedDevices[idx].apnsToken = t.token + pairedDevices[idx].apnsEnvironment = Self.normalizedAPNSEnvironment(t.environment) + } + pairedDevices[idx].lastSeen = .now + for staleIdx in pairedDevices.indices where staleIdx != idx && pairedDevices[staleIdx].pushProvider == provider && pairedDevices[staleIdx].pushToken == t.token { + logger.warning("[Push] clearing duplicate \(provider, privacy: .public) token from stale mobileKey=\(String(self.pairedDevices[staleIdx].pubkeyHex.prefix(12)), privacy: .public) currentMobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.token.prefix(12)), privacy: .public)") + pairedDevices[staleIdx].pushToken = nil + if provider == "apns" { + pairedDevices[staleIdx].apnsToken = nil + pairedDevices[staleIdx].apnsEnvironment = nil + } + } + savePairedDevices() + } else { + logger.warning("[Push] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) provider=\(provider, privacy: .public) tokenPrefix=\(String(t.token.prefix(12)), privacy: .public)") + } case .liveActivityToken(let t): guard let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) else { logger.warning("[LiveActivity] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") diff --git a/RxCode/Services/MobileSyncService+LiveActivity.swift b/RxCode/Services/MobileSyncService+LiveActivity.swift index 7b9779bf..8ceddba0 100644 --- a/RxCode/Services/MobileSyncService+LiveActivity.swift +++ b/RxCode/Services/MobileSyncService+LiveActivity.swift @@ -388,7 +388,7 @@ extension MobileSyncService { if (200..<300).contains(pushResponse.statusCode) { logger.info("[Push] \(pushType, privacy: .public) accepted apnsStatus=\(pushResponse.statusCode, privacy: .public) apnsID=\(pushResponse.apnsID ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") } else { - logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") } } else { logger.info("[Push] \(pushType, privacy: .public) relay accepted httpStatus=\(http.statusCode, privacy: .public) (no APNs detail in response) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 3b369db9..feaeb2c1 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -28,6 +28,8 @@ struct PairedDevice: Codable, Identifiable, Sendable, Hashable { var pubkeyHex: String var displayName: String var platform: String + var pushProvider: String? + var pushToken: String? var apnsToken: String? var apnsEnvironment: String? /// Device-wide Live Activity push-to-start token (iOS 17.2+). Lets the @@ -64,6 +66,8 @@ struct PairedDevice: Codable, Identifiable, Sendable, Hashable { case pubkeyHex case displayName case platform + case pushProvider + case pushToken case apnsToken case apnsEnvironment case liveActivityStartToken @@ -88,6 +92,7 @@ enum MobilePushError: LocalizedError { case invalidRelayURL case relayRejected(status: Int, body: String) case apnsRejected(status: Int, reason: String) + case fcmRejected(status: Int, reason: String) var errorDescription: String? { switch self { @@ -101,6 +106,8 @@ enum MobilePushError: LocalizedError { "Relay rejected the push request (\(status)): \(body)" case .apnsRejected(let status, let reason): "APNs rejected the notification (\(status)): \(reason)" + case .fcmRejected(let status, let reason): + "FCM rejected the notification (\(status)): \(reason)" } } } @@ -433,6 +440,8 @@ final class MobileSyncService: ObservableObject { pubkeyHex: pending.mobilePubkeyHex, displayName: pending.displayName, platform: pending.platform, + pushProvider: nil, + pushToken: nil, apnsToken: nil, apnsEnvironment: Self.normalizedAPNSEnvironment(pending.apnsEnvironment), pairedAt: .now, @@ -531,7 +540,32 @@ final class MobileSyncService: ObservableObject { func broadcastNotification(_ payload: NotificationPayload) { Task { await broadcastToAllClients(.notification(payload)) - await fanoutAPNs(payload) + await fanoutPush(payload) + } + } + + func fanoutPush(_ payload: NotificationPayload) async { + let devices = pairedDevices.filter { Self.pushToken(for: $0)?.isEmpty == false } + guard !devices.isEmpty else { return } + + for device in devices { + guard let relayURLString = device.relayURL, + let relayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[Push] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + continue + } + + do { + switch Self.pushProvider(for: device) { + case "fcm": + try await sendFCMPush(payload, to: device, pushURL: pushURL) + default: + try await sendAPNsPush(payload, to: device, pushURL: pushURL) + } + } catch { + logger.error("[Push] fan-out failed provider=\(Self.pushProvider(for: device), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } } @@ -562,7 +596,7 @@ final class MobileSyncService: ObservableObject { /// endpoint. Throws on a missing token, unknown peer, or relay/APNs /// rejection so callers can log the specific failure. func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { - guard let token = device.apnsToken, !token.isEmpty else { + guard let token = Self.apnsToken(for: device), !token.isEmpty else { throw MobilePushError.missingDeviceToken } // Find the client for this device's relay @@ -585,6 +619,7 @@ final class MobileSyncService: ObservableObject { ) let encryptedAlertData = try JSONEncoder().encode(encrypted) let body = APNsPushRequest( + provider: nil, deviceToken: token, encryptedAlert: encryptedAlertData.base64EncodedString(), category: payload.kind.rawValue, @@ -613,14 +648,84 @@ final class MobileSyncService: ObservableObject { guard (200..<300).contains(pushResponse.statusCode) else { throw MobilePushError.apnsRejected( status: pushResponse.statusCode, - reason: pushResponse.reason + reason: pushResponse.reason ?? "Unknown error" + ) + } + } + + func sendFCMPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { + guard let token = Self.pushToken(for: device), !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.peer(forHex: device.pubkeyHex) else { + throw MobilePushError.unknownPeer + } + + let plaintext = AlertPlaintext( + title: payload.title, + body: payload.body, + sessionID: payload.sessionID, + projectID: payload.projectID, + kind: payload.kind.rawValue + ) + let encrypted = try APNsCrypto.seal( + plaintext: plaintext, + sender: identity.privateKey, + recipient: peer + ) + let encryptedAlertData = try JSONEncoder().encode(encrypted) + let body = PushRequest( + provider: "fcm", + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: payload.kind.rawValue, + collapseID: Self.notificationCollapseID(for: payload, device: device), + apnsEnvironment: nil + ) + + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + logger.info("[FCM] sending push kind=\(payload.kind.rawValue, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) tokenPrefix=\(String(token.prefix(12)), privacy: .public)") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MobilePushError.relayRejected(status: -1, body: "No HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw MobilePushError.relayRejected( + status: http.statusCode, + body: Self.responseBodyString(data) + ) + } + let pushResponse = try JSONDecoder().decode(PushResponse.self, from: data) + guard (200..<300).contains(pushResponse.statusCode) else { + throw MobilePushError.fcmRejected( + status: pushResponse.statusCode, + reason: pushResponse.reason ?? "Unknown error" ) } } /// Send one APNs-backed test notification to a paired device. func sendTestNotification(to device: PairedDevice) async throws { - guard let token = device.apnsToken, !token.isEmpty else { + if Self.pushProvider(for: device) == "fcm" { + guard let deviceRelayURL = pushEndpointURL(for: device) else { + throw MobilePushError.invalidRelayURL + } + let payload = NotificationPayload( + kind: .generic, + title: "RxCode test notification", + body: "Notifications are working for \(device.displayName)." + ) + try await sendFCMPush(payload, to: device, pushURL: deviceRelayURL) + return + } + + guard let token = Self.apnsToken(for: device), !token.isEmpty else { throw MobilePushError.missingDeviceToken } // Find the client for this device's relay @@ -647,6 +752,7 @@ final class MobileSyncService: ObservableObject { ) let encryptedAlertData = try JSONEncoder().encode(encrypted) let body = APNsPushRequest( + provider: nil, deviceToken: token, encryptedAlert: encryptedAlertData.base64EncodedString(), category: "test_notification", @@ -674,7 +780,7 @@ final class MobileSyncService: ObservableObject { guard (200..<300).contains(pushResponse.statusCode) else { throw MobilePushError.apnsRejected( status: pushResponse.statusCode, - reason: pushResponse.reason + reason: pushResponse.reason ?? "Unknown error" ) } } @@ -897,6 +1003,30 @@ final class MobileSyncService: ObservableObject { normalizedAPNSEnvironment(device.apnsEnvironment) } + static func pushProvider(for device: PairedDevice) -> String { + let raw = device.pushProvider? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if raw?.isEmpty == false { + return raw! + } + return (device.apnsToken?.isEmpty == false) ? "apns" : "unknown" + } + + static func pushToken(for device: PairedDevice) -> String? { + if let token = device.pushToken, !token.isEmpty { + return token + } + return device.apnsToken + } + + static func apnsToken(for device: PairedDevice) -> String? { + if pushProvider(for: device) == "apns", let token = device.pushToken, !token.isEmpty { + return token + } + return device.apnsToken + } + static func normalizedAPNSEnvironment(_ environment: String?) -> String? { guard let raw = environment? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -932,7 +1062,8 @@ final class MobileSyncService: ObservableObject { } } -struct APNsPushRequest: Codable { +struct PushRequest: Codable { + let provider: String? let deviceToken: String let encryptedAlert: String let category: String? @@ -940,6 +1071,7 @@ struct APNsPushRequest: Codable { let apnsEnvironment: String? enum CodingKeys: String, CodingKey { + case provider case deviceToken = "device_token" case encryptedAlert = "encrypted_alert" case category @@ -948,20 +1080,28 @@ struct APNsPushRequest: Codable { } } -struct APNsPushResponse: Codable { +typealias APNsPushRequest = PushRequest + +struct PushResponse: Codable { + let provider: String? let statusCode: Int - let reason: String + let reason: String? let apnsID: String? let apnsEnvironment: String? + let messageID: String? enum CodingKeys: String, CodingKey { + case provider case statusCode = "status_code" case reason case apnsID = "apns_id" case apnsEnvironment = "apns_environment" + case messageID = "message_id" } } +typealias APNsPushResponse = PushResponse + extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index aee12c0a..e7d24f44 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -346,7 +346,7 @@ struct MobileSettingsTab: View { .labelStyle(.iconOnly) } .buttonStyle(.borderless) - .disabled(device.apnsToken?.isEmpty ?? true || sync.connectionState != .connected) + .disabled(MobileSyncService.pushToken(for: device)?.isEmpty ?? true || sync.connectionState != .connected) .help(testNotificationHelp(for: device)) } } @@ -371,8 +371,9 @@ struct MobileSettingsTab: View { } private func testNotificationHelp(for device: PairedDevice) -> String { - if device.apnsToken?.isEmpty ?? true { - return "Open RxCode Mobile on this device once so it can register for push notifications." + if MobileSyncService.pushToken(for: device)?.isEmpty ?? true { + let app = MobileSyncService.pushProvider(for: device) == "fcm" ? "RxCode on Android" : "RxCode Mobile" + return "Open \(app) on this device once so it can register for push notifications." } if sync.connectionState != .connected { return "Connect to the relay before sending a test notification." diff --git a/RxCodeAndroid/app/build.gradle.kts b/RxCodeAndroid/app/build.gradle.kts index 5ea035e8..cf0b42a2 100644 --- a/RxCodeAndroid/app/build.gradle.kts +++ b/RxCodeAndroid/app/build.gradle.kts @@ -7,6 +7,11 @@ plugins { alias(libs.plugins.ksp) } +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") + apply(plugin = "com.google.firebase.crashlytics") +} + android { namespace = "app.rxlab.rxcode" compileSdk = 36 @@ -91,6 +96,10 @@ dependencies { implementation(libs.coil.compose) implementation(libs.compose.markdown) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + implementation(libs.firebase.analytics) + implementation(libs.firebase.crashlytics) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/RxCodeAndroid/app/src/main/AndroidManifest.xml b/RxCodeAndroid/app/src/main/AndroidManifest.xml index 27093f5a..af5afc53 100644 --- a/RxCodeAndroid/app/src/main/AndroidManifest.xml +++ b/RxCodeAndroid/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -19,6 +20,14 @@ android:supportsRtl="true" android:theme="@style/Theme.RxCode" tools:targetApi="33"> + + + + + + { "pair_request" -> Payload.PairRequest(json.decodeFromJsonElement(PairRequestPayload.serializer(), data)) "pair_ack" -> Payload.PairAck(json.decodeFromJsonElement(PairAckPayload.serializer(), data)) "unpair" -> Payload.Unpair(json.decodeFromJsonElement(UnpairPayload.serializer(), data)) + "push_token" -> Payload.PushToken(json.decodeFromJsonElement(PushTokenPayload.serializer(), data)) "request_snapshot" -> Payload.RequestSnapshot(json.decodeFromJsonElement(RequestSnapshotPayload.serializer(), data)) "snapshot" -> Payload.Snapshot(json.decodeFromJsonElement(SnapshotPayload.serializer(), data)) "session_update" -> Payload.SessionUpdate(json.decodeFromJsonElement(SessionUpdatePayload.serializer(), data)) @@ -500,6 +511,7 @@ object PayloadSerializer : KSerializer { is Payload.PairRequest -> value.type to json.encodeToJsonElement(PairRequestPayload.serializer(), value.data) is Payload.PairAck -> value.type to json.encodeToJsonElement(PairAckPayload.serializer(), value.data) is Payload.Unpair -> value.type to json.encodeToJsonElement(UnpairPayload.serializer(), value.data) + is Payload.PushToken -> value.type to json.encodeToJsonElement(PushTokenPayload.serializer(), value.data) is Payload.RequestSnapshot -> value.type to json.encodeToJsonElement(RequestSnapshotPayload.serializer(), value.data) is Payload.Snapshot -> value.type to json.encodeToJsonElement(SnapshotPayload.serializer(), value.data) is Payload.SessionUpdate -> value.type to json.encodeToJsonElement(SessionUpdatePayload.serializer(), value.data) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/EncryptedAlert.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/EncryptedAlert.kt new file mode 100644 index 00000000..b610abd4 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/EncryptedAlert.kt @@ -0,0 +1,20 @@ +package app.rxlab.rxcode.push + +import kotlinx.serialization.Serializable + +@Serializable +data class EncryptedAlert( + val v: Int = 1, + val from: String, + val nonce: String, + val ct: String, +) + +@Serializable +data class AlertPlaintext( + val title: String, + val body: String, + val sessionID: String? = null, + val projectID: String? = null, + val kind: String? = null, +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/FcmTokenReporter.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/FcmTokenReporter.kt new file mode 100644 index 00000000..6abd54d7 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/FcmTokenReporter.kt @@ -0,0 +1,49 @@ +package app.rxlab.rxcode.push + +import android.util.Log +import app.rxlab.rxcode.proto.Payload +import app.rxlab.rxcode.proto.PushTokenPayload +import app.rxlab.rxcode.relay.RelayClient +import app.rxlab.rxcode.store.PairingStore +import app.rxlab.rxcode.sync.SyncClient +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +@Singleton +class FcmTokenReporter @Inject constructor( + private val store: PairingStore, + private val client: SyncClient, +) { + suspend fun report(token: String) { + if (token.isBlank()) return + val desktops = store.pairedDesktops.first() + if (desktops.isEmpty()) { + Log.w(TAG, "FCM token report skipped: no paired desktops") + return + } + + for (desktop in desktops) { + desktop.relayUrl?.let { client.setRelayUrl(it) } + client.addPeer(desktop.pubkeyHex) + client.start() + withTimeoutOrNull(CONNECT_TIMEOUT_MS) { + client.connectionState.first { it == RelayClient.ConnectionState.CONNECTED } + } + val sent = client.send( + Payload.PushToken(PushTokenPayload(provider = "fcm", token = token)), + desktop.pubkeyHex, + ) + Log.w( + TAG, + "FCM token report sent=$sent desktop=${desktop.pubkeyHex.take(12)} token=${token.take(12)}", + ) + } + } + + private companion object { + private const val TAG = "FcmTokenReporter" + private const val CONNECT_TIMEOUT_MS = 5_000L + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt new file mode 100644 index 00000000..1fc6369d --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt @@ -0,0 +1,126 @@ +package app.rxlab.rxcode.push + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Base64 +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import app.rxlab.rxcode.MainActivity +import app.rxlab.rxcode.R +import app.rxlab.rxcode.crypto.Hex +import app.rxlab.rxcode.crypto.SessionCrypto +import app.rxlab.rxcode.identity.DeviceIdentity +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.store.PairingStore +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.bouncycastle.crypto.params.X25519PublicKeyParameters + +@AndroidEntryPoint +class RxCodeFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var identity: DeviceIdentity + @Inject lateinit var store: PairingStore + @Inject lateinit var tokenReporter: FcmTokenReporter + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onNewToken(token: String) { + Log.w(TAG, "FCM token refreshed token=${token.take(12)}") + scope.launch { tokenReporter.report(token) } + } + + override fun onMessageReceived(message: RemoteMessage) { + val encrypted = message.data["enc"] ?: message.data["encrypted_alert"] + if (encrypted.isNullOrBlank()) { + Log.w(TAG, "FCM message ignored: missing encrypted alert") + return + } + scope.launch { + runCatching { decryptAlert(encrypted) } + .onSuccess { showNotification(it) } + .onFailure { Log.w(TAG, "FCM alert decrypt failed: ${it.message}", it) } + } + } + + private suspend fun decryptAlert(encryptedAlertBase64: String): AlertPlaintext { + val envelopeJSON = Base64.decode(encryptedAlertBase64, Base64.DEFAULT) + .toString(Charsets.UTF_8) + val envelope = RxJson.decodeFromString(EncryptedAlert.serializer(), envelopeJSON) + val desktop = store.pairedDesktops.first() + .firstOrNull { it.pubkeyHex.equals(envelope.from, ignoreCase = true) } + ?: error("unknown sender ${envelope.from.take(12)}") + val senderBytes = Hex.decode(desktop.pubkeyHex) + ?: error("invalid sender key ${desktop.pubkeyHex.take(12)}") + val nonce = Base64.decode(envelope.nonce, Base64.DEFAULT) + val ciphertext = Base64.decode(envelope.ct, Base64.DEFAULT) + val plaintext = SessionCrypto.open( + ciphertext, + nonce, + identity.privateKey, + X25519PublicKeyParameters(senderBytes, 0), + ) + return RxJson.decodeFromString(AlertPlaintext.serializer(), plaintext.toString(Charsets.UTF_8)) + } + + @SuppressLint("MissingPermission") + private fun showNotification(alert: AlertPlaintext) { + ensureChannel() + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(alert.title) + .setContentText(alert.body) + .setStyle(NotificationCompat.BigTextStyle().bigText(alert.body)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + try { + NotificationManagerCompat.from(this).notify(notificationID(alert), notification) + } catch (security: SecurityException) { + Log.w(TAG, "notification permission missing; dropping FCM alert") + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_HIGH, + ) + ) + } + + private fun notificationID(alert: AlertPlaintext): Int = + (alert.sessionID ?: alert.projectID ?: alert.kind ?: alert.title).hashCode() + + private companion object { + private const val TAG = "RxCodeFCM" + private const val CHANNEL_ID = "rxcode_notifications" + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index 64f9edd1..f12551e4 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -28,10 +28,12 @@ import app.rxlab.rxcode.proto.ThreadActionRequestPayload import app.rxlab.rxcode.proto.ThreadChangesRequestPayload import app.rxlab.rxcode.proto.UserMessagePayload import app.rxlab.rxcode.pairing.PairingToken +import app.rxlab.rxcode.push.FcmTokenReporter import app.rxlab.rxcode.relay.RelayClient import app.rxlab.rxcode.store.PairedDesktop import app.rxlab.rxcode.store.PairingStore import app.rxlab.rxcode.sync.SyncClient +import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job @@ -59,6 +61,7 @@ import java.util.UUID class MobileAppState @Inject constructor( private val store: PairingStore, private val client: SyncClient, + private val fcmTokenReporter: FcmTokenReporter, ) : ViewModel() { private val _state = MutableStateFlow(MobileState(relayUrl = defaultRelayUrl())) val state: StateFlow = _state.asStateFlow() @@ -194,6 +197,7 @@ class MobileAppState @Inject constructor( _state.update { it.copy(pairing = PairingStatus.Idle) } client.addPeer(fromHex) Log.w(TAG, "pairing stored for desktop ${fromHex.take(12)} (${desktop.displayName})") + refreshAndReportFcmToken("paired") requestSnapshot("paired") } @@ -209,7 +213,23 @@ class MobileAppState @Inject constructor( val nextMessages = current.messagesBySession.toMutableMap() val moreSet = current.sessionsWithMoreMessages.toMutableSet() val loadingMore = current.loadingMoreSessions.toMutableSet() + val nextRedirects = current.sessionIDRedirects.toMutableMap() val active = snap.data.activeSessionID + // When the desktop tells us about a real session ID and our local + // activeSessionID is still the optimistic draft from + // `startNewSession`, set up a redirect so any composable still + // holding the draft id (e.g. the chat screen we just navigated to) + // resolves through to the real id and sees the snapshot's messages. + val currentActive = current.activeSessionID + if (active != null && currentActive != null && currentActive != active && isDraftSessionId(currentActive)) { + nextRedirects[currentActive] = active + val carried = nextMessages.remove(currentActive) + if (carried != null && carried.isNotEmpty()) { + val existing = nextMessages[active].orEmpty() + val existingIDs = existing.map { it.id }.toHashSet() + nextMessages[active] = carried.filter { it.id !in existingIDs } + existing + } + } if (active != null) { val msgs = snap.data.activeSessionMessages if (msgs != null) { @@ -224,6 +244,7 @@ class MobileAppState @Inject constructor( projects = snap.data.projects, sessions = snap.data.sessions.sortedWith(SessionSort), activeSessionID = active ?: current.activeSessionID, + sessionIDRedirects = nextRedirects, messagesBySession = nextMessages, sessionsWithMoreMessages = moreSet, loadingMoreSessions = loadingMore, @@ -418,7 +439,12 @@ class MobileAppState @Inject constructor( } } - fun startNewSession(projectId: UUID, initialText: String? = null, planMode: Boolean = false, permissionMode: PermissionMode? = null) { + fun startNewSession( + projectId: UUID, + initialText: String? = null, + planMode: Boolean = false, + permissionMode: PermissionMode? = null, + ): String { val draftId = draftSessionId(projectId) _state.update { it.copy(activeSessionID = draftId, messagesBySession = it.messagesBySession + (draftId to emptyList())) } viewModelScope.launch { @@ -434,6 +460,7 @@ class MobileAppState @Inject constructor( _state.value.activeDesktopPubkey, ) } + return draftId } /** @@ -749,6 +776,21 @@ class MobileAppState @Inject constructor( private fun isActiveDesktop(fromHex: String): Boolean = _state.value.activeDesktopPubkey == fromHex + private fun refreshAndReportFcmToken(reason: String) { + try { + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> + viewModelScope.launch { fcmTokenReporter.report(token) } + Log.w(TAG, "FCM token refresh requested reason=$reason token=${token.take(12)}") + } + .addOnFailureListener { error -> + Log.w(TAG, "FCM token refresh failed reason=$reason: ${error.message}") + } + } catch (t: Throwable) { + Log.w(TAG, "FCM token unavailable reason=$reason: ${t.message}") + } + } + companion object { private const val TAG = "MobileAppState" private const val PAIRING_TIMEOUT_MS = 25_000L diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index f1173396..020489cc 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -42,7 +42,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -50,6 +52,7 @@ import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.MobileThreadSummary import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent import app.rxlab.rxcode.ui.util.RxMarkdownText import app.rxlab.rxcode.ui.util.rememberHaptics @@ -71,7 +74,7 @@ fun BriefingDetailScreen( groupKey: BriefingGroupKey, onBack: () -> Unit, onOpenSession: (String) -> Unit, - onNewThread: () -> Unit, + onNewThread: (sessionId: String) -> Unit, selectedSessionId: String? = null, ) { val haptics = rememberHaptics() @@ -95,6 +98,8 @@ fun BriefingDetailScreen( } val isInitializingGit = groupKey.projectId in state.pendingBranchOps val isUnknownBranch = groupKey.branch.equals("unknown", ignoreCase = true) + val branchInfo = state.projectBranches[groupKey.projectId] + var newThreadOpen by remember { mutableStateOf(false) } Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -114,7 +119,7 @@ fun BriefingDetailScreen( icon = { Icon(Icons.Outlined.Add, contentDescription = null) }, onClick = { haptics.play(HapticEvent.LightTap) - onNewThread() + newThreadOpen = true }, ) }, @@ -172,6 +177,21 @@ fun BriefingDetailScreen( } } } + + if (newThreadOpen) { + NewThreadSheet( + viewModel = viewModel, + projectId = groupKey.projectId, + projectName = project?.name, + branchInfo = branchInfo, + preferredBranch = groupKey.branch.takeIf { !isUnknownBranch }, + onDismiss = { newThreadOpen = false }, + onSessionCreated = { sessionId -> + newThreadOpen = false + onNewThread(sessionId) + }, + ) + } } @Composable diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt index 640df30a..5fcb34f8 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt @@ -187,9 +187,8 @@ private fun CompactBriefingPane( groupKey = key, onBack = onClearGroup, onOpenSession = onSelectSession, - onNewThread = { - viewModel.startNewSession(key.projectId, planMode = false) - state.activeSessionID?.let { onSelectSession(it) } + onNewThread = { newSessionId -> + onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, ) @@ -240,9 +239,8 @@ private fun WideBriefingPane( groupKey = key, onBack = onClearGroup, onOpenSession = onSelectSession, - onNewThread = { - viewModel.startNewSession(key.projectId, planMode = false) - state.activeSessionID?.let { onSelectSession(it) } + onNewThread = { newSessionId -> + onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, ) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt index 8bac1ed4..6fdfebab 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import app.rxlab.rxcode.state.isDraftSessionId import app.rxlab.rxcode.state.resolveSessionId import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -122,11 +123,14 @@ private fun CollapsingProjectsPane( // React to a cross-tab navigation (Briefing → thread). When activeSessionID // changes from outside this scaffold, locate its owning project, drill the // list pane into that project's sessions, and push the chat detail pane. + // Skip draft ids: `startNewSession` flips activeSessionID to a draft id + // before the desktop has assigned a real session, and the new-thread sheet + // already owns navigation for that flow once the real session lands. LaunchedEffect(state.activeSessionID, state.sessions) { val sid = state.activeSessionID ?: return@LaunchedEffect + if (isDraftSessionId(sid)) return@LaunchedEffect val resolved = state.resolveSessionId(sid) val pid = state.sessions.firstOrNull { it.id == resolved || it.id == sid }?.projectId - ?: projectIdFromDraftSession(sid) if (pid != null && selectedProjectId != pid) { onSelectProject(pid) } @@ -159,13 +163,10 @@ private fun CollapsingProjectsPane( navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, session.id) } }, - onNewThread = { planMode -> - viewModel.startNewSession(pid, planMode = planMode) - val draftId = state.activeSessionID - if (draftId != null) { - scope.launch { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, draftId) - } + onNewThread = { sessionId -> + viewModel.selectSession(sessionId) + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, sessionId) } }, onBack = onBackToProjects, @@ -210,9 +211,9 @@ private fun WideProjectsPane( LaunchedEffect(state.activeSessionID, state.sessions) { val sid = state.activeSessionID ?: return@LaunchedEffect + if (isDraftSessionId(sid)) return@LaunchedEffect val resolved = state.resolveSessionId(sid) val pid = state.sessions.firstOrNull { it.id == resolved || it.id == sid }?.projectId - ?: projectIdFromDraftSession(sid) if (pid != null && selectedProjectId != pid) { onSelectProject(pid) } @@ -259,9 +260,9 @@ private fun WideProjectsPane( viewModel.selectSession(session.id) selectedSessionId = session.id }, - onNewThread = { planMode -> - viewModel.startNewSession(pid, planMode = planMode) - state.activeSessionID?.let { selectedSessionId = it } + onNewThread = { sessionId -> + viewModel.selectSession(sessionId) + selectedSessionId = sessionId }, onBack = onBackToProjects, viewModel = viewModel, @@ -384,15 +385,3 @@ private val uuidSaver: Saver = Saver( restore = { if (it.isEmpty()) null else UUID.fromString(it) }, ) -/** - * `startNewSession` synthesizes a draft id of the form `draft-new::` - * before the desktop assigns a real one. Pull the project id out of it so we - * can drill into the right project's sessions list while waiting for the real - * session id to land. - */ -private fun projectIdFromDraftSession(id: String): UUID? { - if (!id.startsWith("draft-new:")) return null - val parts = id.removePrefix("draft-new:").split(":") - val raw = parts.firstOrNull() ?: return null - return runCatching { UUID.fromString(raw) }.getOrNull() -} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index 421a1112..3f2ab8c4 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -82,9 +82,9 @@ fun SessionsScreen( state: MobileState, projectId: UUID, onSessionClick: (SessionSummary) -> Unit, - onNewThread: (planMode: Boolean) -> Unit, + onNewThread: (sessionId: String) -> Unit, onBack: () -> Unit, - viewModel: MobileAppState? = null, + viewModel: MobileAppState, selectedSessionId: String? = null, ) { val haptics = rememberHaptics() @@ -127,7 +127,7 @@ fun SessionsScreen( onRefresh = { haptics.play(HapticEvent.LightTap) refreshing = true - viewModel?.requestSnapshot("pull_to_refresh") + viewModel.requestSnapshot("pull_to_refresh") scope.launch { delay(800) refreshing = false @@ -158,7 +158,7 @@ fun SessionsScreen( onRename = { renameTarget = session }, onArchive = { haptics.play(HapticEvent.HeavyImpact) - viewModel?.archiveThread(session.id) + viewModel.archiveThread(session.id) }, onDelete = { deleteTarget = session }, ) @@ -170,12 +170,14 @@ fun SessionsScreen( if (newThreadOpen) { NewThreadSheet( + viewModel = viewModel, projectId = projectId, + projectName = project?.name, branchInfo = branchInfo, onDismiss = { newThreadOpen = false }, - onSubmit = { planMode, _, _ -> + onSessionCreated = { sessionId -> newThreadOpen = false - onNewThread(planMode) + onNewThread(sessionId) }, ) } @@ -185,7 +187,7 @@ fun SessionsScreen( currentTitle = target.title, onDismiss = { renameTarget = null }, onSubmit = { newTitle -> - viewModel?.renameThread(target.id, newTitle) + viewModel.renameThread(target.id, newTitle) renameTarget = null }, ) @@ -204,7 +206,7 @@ fun SessionsScreen( confirmButton = { androidx.compose.material3.TextButton(onClick = { haptics.play(HapticEvent.HeavyImpact) - viewModel?.deleteThread(target.id) + viewModel.deleteThread(target.id) deleteTarget = null }) { Text("Delete", color = MaterialTheme.colorScheme.error) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt index 401146a7..b0157689 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt @@ -1,28 +1,40 @@ package app.rxlab.rxcode.ui.sheets +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.EditNote +import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Lightbulb -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -31,127 +43,295 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.PermissionMode import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.ui.util.HapticEvent import app.rxlab.rxcode.ui.util.rememberHaptics import java.util.UUID /** - * New-thread bottom sheet — mirrors iOS `NewThreadSheet`. Shows the project's - * current and available branches (read from the most-recent - * `project_branches` snapshot), a plan-mode toggle, and an initial prompt - * field. The "Start" button is enabled as soon as we have a branch resolved. - * - * Picking a different branch from the current one isn't wired to - * `branch_op_request` yet — for now we surface the picker so the user can - * verify which branch the desktop will run on. A future change can switch the - * branch via `BranchOpRequestPayload.Operation.SWITCH_EXISTING` before the - * thread is created. + * New-thread bottom sheet. Mirrors iOS `NewThreadSheet`: a hero header that + * names the project, a rounded composer card with an embedded gradient send + * button, and a chip strip for the per-thread agent config (branch, permission + * mode, plan mode). The branch chip only updates the local picker state — the + * actual switch happens on the desktop the next time we wire up + * `branch_op_request`. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewThreadSheet( + viewModel: MobileAppState, projectId: UUID, + projectName: String?, branchInfo: ProjectBranchInfo?, preferredBranch: String? = null, onDismiss: () -> Unit, - onSubmit: (planMode: Boolean, initialText: String, branch: String?) -> Unit, + onSessionCreated: (sessionId: String) -> Unit, ) { val haptics = rememberHaptics() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + var planMode by remember { mutableStateOf(false) } + var permissionMode by remember { mutableStateOf(PermissionMode.DEFAULT) } var prompt by remember { mutableStateOf("") } val resolvedBranch = remember(branchInfo, preferredBranch) { preferredBranch ?: branchInfo?.currentBranch } var selectedBranch by remember(resolvedBranch) { mutableStateOf(resolvedBranch) } + val canSend = prompt.trim().isNotEmpty() + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dragHandle = null, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Text("New thread", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + TopBar(onCancel = onDismiss) - // Branch row - BranchSelector( - branchInfo = branchInfo, - selected = selectedBranch, - onSelect = { - haptics.play(HapticEvent.Selection) - selectedBranch = it + HeroHeader(projectName = projectName) + + ComposerCard( + text = prompt, + onTextChange = { prompt = it }, + canSend = canSend, + onSend = { + if (canSend) { + haptics.play(HapticEvent.LightTap) + val draftId = viewModel.startNewSession( + projectId = projectId, + initialText = prompt.trim(), + planMode = planMode, + permissionMode = permissionMode.takeIf { it != PermissionMode.DEFAULT }, + ) + onSessionCreated(draftId) + } }, ) - HorizontalDivider() - - ListItem( - headlineContent = { Text("Plan mode") }, - supportingContent = { - Text("Ask Claude to propose a plan before editing.") + SettingsStrip( + branchInfo = branchInfo, + selectedBranch = selectedBranch, + onSelectBranch = { + haptics.play(HapticEvent.Selection) + selectedBranch = it }, - leadingContent = { - Icon(Icons.Outlined.Lightbulb, contentDescription = null) + permissionMode = permissionMode, + onSelectPermission = { + haptics.play(HapticEvent.Selection) + permissionMode = it }, - trailingContent = { - Switch( - checked = planMode, - onCheckedChange = { - haptics.play(HapticEvent.Selection) - planMode = it - }, - ) + planMode = planMode, + onTogglePlan = { + haptics.play(HapticEvent.Selection) + planMode = !planMode }, - modifier = Modifier.padding(horizontal = 0.dp), ) - OutlinedTextField( - value = prompt, - onValueChange = { prompt = it }, - modifier = Modifier - .fillMaxWidth() - .height(140.dp), - label = { Text("Initial message (optional)") }, - placeholder = { Text("What would you like to work on?") }, + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun TopBar(onCancel: () -> Unit) { + Box(Modifier.fillMaxWidth().padding(top = 4.dp)) { + TextButton( + onClick = onCancel, + modifier = Modifier.align(Alignment.CenterStart), + ) { Text("Cancel") } + Text( + text = "New Thread", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +private fun HeroHeader(projectName: String?) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(54.dp) + .clip(CircleShape) + .background(accentGradient(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.AutoAwesome, + contentDescription = null, + tint = Color(0xFFE8704C), + modifier = Modifier.size(28.dp), ) + } + Text( + text = "What should we build in ${projectName ?: "this project"}?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = "Describe a task, paste a snippet, or ask a question.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - TextButton( - onClick = onDismiss, - modifier = Modifier.padding(vertical = 4.dp), - ) { Text("Cancel") } - Spacer(Modifier.weight(1f)) - Button( - onClick = { - haptics.play(HapticEvent.LightTap) - onSubmit(planMode, prompt.trim(), selectedBranch) - }, - enabled = true, - colors = ButtonDefaults.buttonColors(), - ) { Text("Start") } +@Composable +private fun ComposerCard( + text: String, + onTextChange: (String) -> Unit, + canSend: Boolean, + onSend: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + tonalElevation = 0.dp, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box { + if (text.isEmpty()) { + Text( + text = "Describe what to build…", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + BasicTextField( + value = text, + onValueChange = onTextChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 96.dp, max = 240.dp), + ) } - Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + SendButton(enabled = canSend, onClick = onSend) + } + } + } +} + +@Composable +private fun SendButton(enabled: Boolean, onClick: () -> Unit) { + val background = if (enabled) accentGradient() else SolidColor( + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + val contentColor = if (enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(background) + .clickable(enabled = enabled, onClick = onClick) + .graphicsLayer { alpha = if (enabled) 1f else 0.85f }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = "Send", + tint = contentColor, + modifier = Modifier + .size(18.dp) + .graphicsLayer { rotationZ = -90f }, + ) + } +} + +@Composable +private fun SettingsStrip( + branchInfo: ProjectBranchInfo?, + selectedBranch: String?, + onSelectBranch: (String) -> Unit, + permissionMode: PermissionMode, + onSelectPermission: (PermissionMode) -> Unit, + planMode: Boolean, + onTogglePlan: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "SETTINGS", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + BranchChip( + branchInfo = branchInfo, + selected = selectedBranch, + onSelect = onSelectBranch, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PermissionChip( + mode = permissionMode, + onSelect = onSelectPermission, + modifier = Modifier.weight(1f), + ) + PlanToggleChip( + active = planMode, + onClick = onTogglePlan, + modifier = Modifier.weight(1f), + ) } } } @Composable -private fun BranchSelector( +private fun BranchChip( branchInfo: ProjectBranchInfo?, selected: String?, onSelect: (String) -> Unit, ) { + var open by remember { mutableStateOf(false) } val branches = remember(branchInfo) { - val all = branchInfo?.availableBranches.orEmpty() val current = branchInfo?.currentBranch + val all = branchInfo?.availableBranches.orEmpty() when { all.isNotEmpty() && current != null -> listOf(current) + all.filter { it != current } all.isNotEmpty() -> all @@ -159,77 +339,175 @@ private fun BranchSelector( else -> emptyList() } } + val label = selected ?: branchInfo?.currentBranch ?: "No branch" - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.Outlined.AccountTree, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - Text("Branch", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Box(Modifier.fillMaxWidth()) { + ChipSurface( + icon = Icons.Outlined.AccountTree, + title = label, + showsChevron = branches.isNotEmpty(), + enabled = branches.isNotEmpty(), + onClick = { if (branches.isNotEmpty()) open = true }, + modifier = Modifier.fillMaxWidth(), + ) + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + ) { + branches.forEach { branch -> + val isSelected = branch == (selected ?: branchInfo?.currentBranch) + DropdownMenuItem( + text = { Text(branch) }, + leadingIcon = { + Icon( + if (isSelected) Icons.Outlined.Check else Icons.Outlined.AccountTree, + contentDescription = null, + ) + }, + onClick = { + open = false + onSelect(branch) + }, + ) + } } - if (branches.isEmpty()) { - Text( - "No branch info yet — the thread will run on whatever branch the desktop has checked out.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - branches.forEach { branch -> - BranchRow( - branch = branch, - isSelected = branch == selected, - onClick = { onSelect(branch) }, - ) - } + } +} + +@Composable +private fun PermissionChip( + mode: PermissionMode, + onSelect: (PermissionMode) -> Unit, + modifier: Modifier = Modifier, +) { + var open by remember { mutableStateOf(false) } + val pickable = listOf( + PermissionMode.DEFAULT, + PermissionMode.ACCEPT_EDITS, + PermissionMode.BYPASS_PERMISSIONS, + ) + + Box(modifier) { + ChipSurface( + icon = mode.icon(), + title = mode.displayName(), + showsChevron = true, + onClick = { open = true }, + modifier = Modifier.fillMaxWidth(), + ) + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + ) { + pickable.forEach { item -> + val isSelected = item == mode + DropdownMenuItem( + text = { Text(item.displayName()) }, + leadingIcon = { + Icon(item.icon(), contentDescription = null) + }, + trailingIcon = { + if (isSelected) Icon(Icons.Outlined.Check, contentDescription = null) + }, + onClick = { + open = false + onSelect(item) + }, + ) } } } } @Composable -private fun BranchRow( - branch: String, - isSelected: Boolean, +private fun PlanToggleChip( + active: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { - Surface( + ChipSurface( + icon = Icons.Outlined.Lightbulb, + title = "Plan mode", + showsChevron = false, + active = active, onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerLow, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ChipSurface( + icon: ImageVector, + title: String, + showsChevron: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + active: Boolean = false, +) { + val shape = RoundedCornerShape(14.dp) + val container = when { + active -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } + val content = when { + active -> MaterialTheme.colorScheme.onPrimary + enabled -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + Surface( + shape = shape, + color = container, + contentColor = content, + modifier = modifier + .clip(shape) + .clickable(enabled = enabled, onClick = onClick), ) { Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Icon( - Icons.Outlined.AccountTree, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurfaceVariant, - ) + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) Text( - branch, + text = title, style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), + fontWeight = FontWeight.Medium, + color = LocalContentColor.current, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), ) - if (isSelected) { + if (showsChevron) { + Spacer(Modifier.weight(1f)) Icon( - Icons.Outlined.Check, + Icons.Outlined.ExpandMore, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(14.dp), + tint = LocalContentColor.current.copy(alpha = 0.7f), ) } } } } + +private fun accentGradient(alpha: Float = 1f): Brush = Brush.linearGradient( + colors = listOf( + Color(red = 0.95f, green = 0.6f, blue = 0.4f, alpha = alpha), + Color(red = 0.85f, green = 0.5f, blue = 0.55f, alpha = alpha), + Color(red = 0.65f, green = 0.5f, blue = 0.7f, alpha = alpha), + ), +) + +private fun PermissionMode.displayName(): String = when (this) { + PermissionMode.DEFAULT -> "Default" + PermissionMode.ACCEPT_EDITS -> "Accept edits" + PermissionMode.BYPASS_PERMISSIONS -> "Bypass" + PermissionMode.PLAN -> "Plan" +} + +private fun PermissionMode.icon(): ImageVector = when (this) { + PermissionMode.DEFAULT -> Icons.Outlined.Shield + PermissionMode.ACCEPT_EDITS -> Icons.Outlined.EditNote + PermissionMode.BYPASS_PERMISSIONS -> Icons.Outlined.LockOpen + PermissionMode.PLAN -> Icons.Outlined.Lightbulb +} diff --git a/RxCodeAndroid/build.gradle.kts b/RxCodeAndroid/build.gradle.kts index 721a456b..2517e19d 100644 --- a/RxCodeAndroid/build.gradle.kts +++ b/RxCodeAndroid/build.gradle.kts @@ -5,4 +5,6 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } diff --git a/RxCodeAndroid/gradle/libs.versions.toml b/RxCodeAndroid/gradle/libs.versions.toml index 23f08de7..4a3d666a 100644 --- a/RxCodeAndroid/gradle/libs.versions.toml +++ b/RxCodeAndroid/gradle/libs.versions.toml @@ -22,6 +22,9 @@ accompanistPermissions = "0.34.0" coil = "2.7.0" composeMarkdown = "0.5.4" androidxWebkit = "1.12.1" +firebaseBom = "33.5.1" +googleServices = "4.4.2" +crashlyticsPlugin = "3.0.2" ksp = "2.0.21-1.0.27" junit = "4.13.2" junitVersion = "1.2.1" @@ -72,6 +75,10 @@ accompanist-permissions = { group = "com.google.accompanist", name = "accompanis coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -84,3 +91,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlyticsPlugin" } diff --git a/RxCodeMobile/FirebaseBootstrap.swift b/RxCodeMobile/FirebaseBootstrap.swift new file mode 100644 index 00000000..9121ae90 --- /dev/null +++ b/RxCodeMobile/FirebaseBootstrap.swift @@ -0,0 +1,19 @@ +import FirebaseCore +import FirebaseCrashlytics +import Foundation +import os.log + +enum FirebaseBootstrap { + private static let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "Firebase") + + static func configure() { + guard FirebaseApp.app() == nil else { return } + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") + return + } + FirebaseApp.configure() + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + logger.info("Firebase configured") + } +} diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index c090a8b9..e14eb455 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -11,6 +11,7 @@ struct RxCodeMobileApp: App { @Environment(\.scenePhase) private var scenePhase init() { + FirebaseBootstrap.configure() try? Tips.configure([ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault), diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh index 9405ab1d..8afb6a24 100755 --- a/ci_scripts/ci_post_clone.sh +++ b/ci_scripts/ci_post_clone.sh @@ -22,6 +22,10 @@ echo "Repo root: $REPO_ROOT" defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES || true defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES || true +# Materialize Firebase config files from Xcode Cloud env secrets. +# FIREBASE_MACOS_B64 / FIREBASE_IOS_B64 must be defined as workflow secrets. +"$REPO_ROOT/scripts/ci/write-firebase-config.sh" + if [[ -n "${CI_TAG:-}" ]]; then VERSION="${CI_TAG#v}" if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][A-Za-z0-9.]+)?$ ]]; then diff --git a/relay-server/README.md b/relay-server/README.md index 03f74a87..1399c376 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -1,6 +1,6 @@ # rxcode-relay -Stateless WebSocket relay + APNs forwarder for the RxCode desktop ↔ mobile sync channel. +Stateless WebSocket relay + APNs/FCM forwarder for the RxCode desktop ↔ mobile sync channel. The relay never decrypts payloads. All sync messages are E2E encrypted between device pairs using Curve25519 + ChaCha20-Poly1305; the relay only sees opaque @@ -13,8 +13,9 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. already prevents reading or forging messages. Drop-on-offline: if the recipient pubkey isn't currently connected, the envelope is dropped and the sender receives a `delivery_failed` notice. -- `POST /push` — desktop submits APNs pushes. The `push_type` field selects - one of three delivery modes (defaults to `alert`): +- `POST /push` — desktop submits APNs or FCM pushes. The optional `provider` + field selects `"apns"` (default) or `"fcm"`. For APNs, the `push_type` field + selects one of three delivery modes (defaults to `alert`): - **`alert`** (or omitted) — encrypted banner. Body: ```json { @@ -49,6 +50,18 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. It keeps both sandbox and production APNs clients alive, then routes each push by `apns_environment`. If older desktop clients omit the field, `APNS_PRODUCTION` is used as a compatibility default. + - **FCM alert** — encrypted Android banner. Body: + ```json + { + "provider": "fcm", + "device_token": "", + "encrypted_alert": "", + "category": "permission_request", + "collapse_id": "" + } + ``` + The relay sends an FCM HTTP v1 data message. The Android app decrypts + `enc` locally and renders the system notification itself. - `GET /healthz` — liveness probe. ## Run locally @@ -78,6 +91,10 @@ missing file is non-fatal — the relay just uses whatever's in the process env. | `-apns-team-id` | `APNS_TEAM_ID` | 10-char Team ID. | | `-apns-topic` | `APNS_TOPIC` | iOS app bundle identifier (e.g. `app.rxlab.rxcodemobile`). | | `-apns-production` | `APNS_PRODUCTION` | Compatibility default when a push omits `apns_environment`. | +| `-fcm-project-id` | `FCM_PROJECT_ID` | Firebase project ID. Optional if the service-account JSON contains `project_id`. | +| `-fcm-service-account` | `GOOGLE_APPLICATION_CREDENTIALS` | Path to Firebase service-account JSON. | +| *(none)* | `FCM_SERVICE_ACCOUNT_JSON` | Raw Firebase service-account JSON. | +| *(none)* | `FCM_SERVICE_ACCOUNT_B64` | Base64-encoded Firebase service-account JSON, preferred for container secrets. | | `-redis-url` | `REDIS_URL` | Redis URL for the multi-node backplane. Empty = single-node. | `APNS_KEY_B64` wins over `APNS_KEY_PATH` when both are set. Both standard and @@ -95,6 +112,8 @@ APNS_KEY_ID=ABCDE12345 APNS_TEAM_ID=YYYYYYYYYY APNS_TOPIC=app.rxlab.rxcodemobile APNS_PRODUCTION=false # fallback only for old desktop builds +FCM_PROJECT_ID=your-firebase-project +FCM_SERVICE_ACCOUNT_B64=ewogICJ0eXBlIjog... # base64 of service account JSON ``` Encode the `.p8` ready for the file: diff --git a/relay-server/fcm.go b/relay-server/fcm.go new file mode 100644 index 00000000..97cb9073 --- /dev/null +++ b/relay-server/fcm.go @@ -0,0 +1,265 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +const firebaseMessagingScope = "https://www.googleapis.com/auth/firebase.messaging" + +type FCMSender struct { + projectID string + clientEmail string + privateKey any + tokenURI string + httpClient *http.Client + + mu sync.Mutex + accessToken string + tokenExpiry time.Time +} + +type fcmServiceAccount struct { + ProjectID string `json:"project_id"` + ClientEmail string `json:"client_email"` + PrivateKey string `json:"private_key"` + TokenURI string `json:"token_uri"` +} + +func NewFCMSender(projectID string, serviceAccountJSON []byte) (*FCMSender, error) { + var account fcmServiceAccount + if err := json.Unmarshal(serviceAccountJSON, &account); err != nil { + return nil, fmt.Errorf("parse fcm service account: %w", err) + } + if projectID == "" { + projectID = account.ProjectID + } + if projectID == "" || account.ClientEmail == "" || account.PrivateKey == "" { + return nil, fmt.Errorf("fcm project id, client email, and private key are required") + } + tokenURI := account.TokenURI + if tokenURI == "" { + tokenURI = "https://oauth2.googleapis.com/token" + } + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("parse fcm private key: %w", err) + } + return &FCMSender{ + projectID: projectID, + clientEmail: account.ClientEmail, + privateKey: privateKey, + tokenURI: tokenURI, + httpClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func loadFCMServiceAccount(path, raw, rawB64 string) ([]byte, string, error) { + switch { + case strings.TrimSpace(raw) != "": + return []byte(raw), "env(FCM_SERVICE_ACCOUNT_JSON)", nil + case strings.TrimSpace(rawB64) != "": + cleaned := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, rawB64) + if b, err := base64.StdEncoding.DecodeString(cleaned); err == nil { + return b, "env(FCM_SERVICE_ACCOUNT_B64)", nil + } + b, err := base64.URLEncoding.DecodeString(cleaned) + if err != nil { + return nil, "", err + } + return b, "env(FCM_SERVICE_ACCOUNT_B64)", nil + case strings.TrimSpace(path) != "": + b, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return b, "file", nil + default: + return nil, "", nil + } +} + +func (s *FCMSender) Send(ctx context.Context, req *PushRequest) (*FCMPushResponse, error) { + if req.EncryptedAlertB64 == "" { + return nil, fmt.Errorf("encrypted_alert is required for fcm") + } + token, err := s.bearerToken(ctx) + if err != nil { + return nil, err + } + + data := map[string]string{ + "enc": req.EncryptedAlertB64, + "encrypted_alert": req.EncryptedAlertB64, + } + if req.Category != "" { + data["category"] = req.Category + } + if req.CollapseID != "" { + data["collapse_id"] = req.CollapseID + } + + android := map[string]any{"priority": "HIGH"} + if req.CollapseID != "" { + android["collapse_key"] = req.CollapseID + } + body := map[string]any{ + "message": map[string]any{ + "token": req.DeviceToken, + "data": data, + "android": android, + }, + } + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", url.PathEscape(s.projectID)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + + res, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer res.Body.Close() + responseBody, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024)) + out := &FCMPushResponse{StatusCode: res.StatusCode} + if res.StatusCode >= 200 && res.StatusCode < 300 { + var success struct { + Name string `json:"name"` + } + _ = json.Unmarshal(responseBody, &success) + out.MessageID = success.Name + return out, nil + } + + var failure struct { + Error struct { + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + _ = json.Unmarshal(responseBody, &failure) + out.Reason = failure.Error.Message + if out.Reason == "" { + out.Reason = strings.TrimSpace(string(responseBody)) + } + return out, nil +} + +func (s *FCMSender) bearerToken(ctx context.Context) (string, error) { + s.mu.Lock() + if s.accessToken != "" && time.Now().Before(s.tokenExpiry.Add(-1*time.Minute)) { + token := s.accessToken + s.mu.Unlock() + return token, nil + } + s.mu.Unlock() + + now := time.Now() + claims := jwt.MapClaims{ + "iss": s.clientEmail, + "scope": firebaseMessagingScope, + "aud": s.tokenURI, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } + assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.privateKey) + if err != nil { + return "", err + } + values := url.Values{} + values.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + values.Set("assertion", assertion) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.tokenURI, strings.NewReader(values.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024)) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", fmt.Errorf("fcm token exchange failed (%d): %s", res.StatusCode, strings.TrimSpace(string(body))) + } + var tokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", err + } + if tokenResponse.AccessToken == "" { + return "", fmt.Errorf("fcm token exchange returned empty access token") + } + expiresIn := tokenResponse.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + s.mu.Lock() + s.accessToken = tokenResponse.AccessToken + s.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second) + s.mu.Unlock() + return tokenResponse.AccessToken, nil +} + +type FCMPushResponse struct { + StatusCode int `json:"status_code"` + Reason string `json:"reason,omitempty"` + MessageID string `json:"message_id,omitempty"` +} + +func sendFCMResponse(w http.ResponseWriter, sender *FCMSender, req *PushRequest) { + log.Printf( + "fcm push send: device=%s category=%q collapse_id=%q encrypted_bytes=%d", + short(req.DeviceToken), req.Category, req.CollapseID, len(req.EncryptedAlertB64), + ) + res, err := sender.Send(context.Background(), req) + if err != nil { + log.Printf("fcm push transport error: %v device=%s category=%q", err, short(req.DeviceToken), req.Category) + http.Error(w, "fcm push failed", http.StatusBadGateway) + return + } + if res.StatusCode >= 200 && res.StatusCode < 300 { + log.Printf("fcm push sent: status=%d message_id=%s device=%s", res.StatusCode, res.MessageID, short(req.DeviceToken)) + } else { + log.Printf("fcm push rejected: status=%d reason=%q device=%s", res.StatusCode, res.Reason, short(req.DeviceToken)) + } + resp := map[string]any{ + "provider": pushProviderFCM, + "status_code": res.StatusCode, + "reason": res.Reason, + "message_id": res.MessageID, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/relay-server/go.mod b/relay-server/go.mod index 5397475a..8381e400 100644 --- a/relay-server/go.mod +++ b/relay-server/go.mod @@ -3,6 +3,7 @@ module github.com/rxlab/rxcode-relay go 1.24 require ( + github.com/golang-jwt/jwt/v4 v4.5.1 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.19.0 @@ -11,7 +12,6 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/text v0.19.0 // indirect diff --git a/relay-server/health.go b/relay-server/health.go index 4cb80594..626de5e9 100644 --- a/relay-server/health.go +++ b/relay-server/health.go @@ -11,7 +11,7 @@ var startedAt = time.Now() // healthHandler returns a JSON liveness probe with current connection count // and APNs availability. Used by orchestrators and by the desktop "Mobile" // settings tab to verify the configured relay is reachable. -func healthHandler(hub *Hub, sender *PushSender) http.HandlerFunc { +func healthHandler(hub *Hub, apnsSender *PushSender, fcmSender *FCMSender) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mode := "single-node" if hub.backplane != nil { @@ -21,7 +21,8 @@ func healthHandler(hub *Hub, sender *PushSender) http.HandlerFunc { "ok": true, "uptime_sec": int(time.Since(startedAt).Seconds()), "peers": hub.ConnectedCount(), - "apns": sender != nil, + "apns": apnsSender != nil, + "fcm": fcmSender != nil, "mode": mode, "version": "0.2.0", } diff --git a/relay-server/k8s/README.md b/relay-server/k8s/README.md index 4e65cf26..2a8fdf8a 100644 --- a/relay-server/k8s/README.md +++ b/relay-server/k8s/README.md @@ -18,8 +18,8 @@ Everything lives in the `rxcode-relay` namespace. | File | Purpose | |------|---------| | `namespace.yaml` | `rxcode-relay` namespace | -| `configmap.yaml` | Non-secret config — `RELAY_ADDR`, `APNS_TOPIC`, `APNS_PRODUCTION`, `REDIS_URL` | -| `secrets.yaml` | APNs auth credentials (placeholder values — **not** in kustomization) | +| `configmap.yaml` | Non-secret config — `RELAY_ADDR`, `APNS_TOPIC`, `APNS_PRODUCTION`, `REDIS_URL`, `FCM_PROJECT_ID` | +| `secrets.yaml` | APNs + FCM credentials (placeholder values — **not** in kustomization) | | `deployment.yaml` | Relay Deployment, 2 replicas, `/healthz` probes | | `service.yaml` | ClusterIP on port 8787 | | `ingress.yaml` | NGINX ingress for `relay.rxlab.app` with WebSocket timeouts | @@ -39,16 +39,25 @@ Everything lives in the `rxcode-relay` namespace. ### 1. Populate secrets -Edit `secrets.yaml` with real APNs values, then apply it manually (it is +Edit `secrets.yaml` with real APNs + FCM values, then apply it manually (it is deliberately excluded from `kustomization.yaml`): ```bash -# encode the .p8 auth key +# APNs: encode the .p8 auth key base64 < AuthKey_XXXXXXXXXX.p8 | tr -d '\n' +# FCM: encode the Firebase service-account JSON +# (Firebase Console → Project settings → Service accounts → Generate new +# private key. This is the server-side service-account file, NOT the +# google-services.json that ships in the Android app.) +base64 < firebase-service-account.json | tr -d '\n' + kubectl apply -f secrets.yaml ``` +The relay only sends FCM pushes when `FCM_SERVICE_ACCOUNT_B64` is non-empty; +leave it blank to disable Android push delivery while keeping APNs working. + ### 2. Deploy ```bash diff --git a/relay-server/k8s/configmap.yaml b/relay-server/k8s/configmap.yaml index f2884a44..b0e307db 100644 --- a/relay-server/k8s/configmap.yaml +++ b/relay-server/k8s/configmap.yaml @@ -17,3 +17,7 @@ data: # so the relay can run multiple replicas. Reachable from any namespace via # this FQDN; auth-less. Remove this key to fall back to single-node mode. REDIS_URL: "redis://redis.redis.svc.cluster.local:6379" + # Firebase project ID used when forwarding Android pushes via the FCM HTTP v1 + # API. The corresponding service-account JSON is provided through + # FCM_SERVICE_ACCOUNT_B64 in `secrets.yaml`. Non-sensitive on its own. + FCM_PROJECT_ID: "xcode-64741" diff --git a/relay-server/main.go b/relay-server/main.go index e0856f7e..6b770d0a 100644 --- a/relay-server/main.go +++ b/relay-server/main.go @@ -39,6 +39,8 @@ func main() { apnsTeamID := flag.String("apns-team-id", os.Getenv("APNS_TEAM_ID"), "APNs Team ID (env: APNS_TEAM_ID)") apnsTopic := flag.String("apns-topic", os.Getenv("APNS_TOPIC"), "APNs topic / iOS bundle ID (env: APNS_TOPIC)") apnsProduction := flag.Bool("apns-production", envBool("APNS_PRODUCTION", false), "use production APNs endpoint instead of sandbox (env: APNS_PRODUCTION)") + fcmProjectID := flag.String("fcm-project-id", os.Getenv("FCM_PROJECT_ID"), "Firebase project ID (env: FCM_PROJECT_ID)") + fcmServiceAccountPath := flag.String("fcm-service-account", os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), "path to Firebase service-account JSON (env: GOOGLE_APPLICATION_CREDENTIALS)") redisURL := flag.String("redis-url", os.Getenv("REDIS_URL"), "Redis URL for the multi-node pub/sub backplane; empty runs single-node (env: REDIS_URL)") flag.Parse() @@ -84,10 +86,30 @@ func main() { log.Printf("APNs sender disabled (set APNS_KEY_B64 or -apns-key to enable)") } + var fcmSender *FCMSender + fcmJSON, fcmSource, err := loadFCMServiceAccount( + *fcmServiceAccountPath, + os.Getenv("FCM_SERVICE_ACCOUNT_JSON"), + os.Getenv("FCM_SERVICE_ACCOUNT_B64"), + ) + if err != nil { + log.Fatalf("load FCM service account: %v", err) + } + if len(fcmJSON) > 0 { + p, err := NewFCMSender(*fcmProjectID, fcmJSON) + if err != nil { + log.Fatalf("init FCM sender: %v", err) + } + fcmSender = p + log.Printf("FCM sender enabled (project=%s key=%s)", p.projectID, fcmSource) + } else { + log.Printf("FCM sender disabled (set FCM_SERVICE_ACCOUNT_B64, FCM_SERVICE_ACCOUNT_JSON, or GOOGLE_APPLICATION_CREDENTIALS to enable)") + } + mux := http.NewServeMux() mux.HandleFunc("/ws", hub.ServeWS) - mux.HandleFunc("/push", pushHandler(pushSender)) - mux.HandleFunc("/healthz", healthHandler(hub, pushSender)) + mux.HandleFunc("/push", pushHandler(pushSender, fcmSender)) + mux.HandleFunc("/healthz", healthHandler(hub, pushSender, fcmSender)) srv := &http.Server{ Addr: *addr, diff --git a/relay-server/push.go b/relay-server/push.go index 402e5173..df9c2ba5 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -73,6 +73,9 @@ func NewPushSender(keyPath string, keyPEM []byte, keyID, teamID, topic string, p // Push delivery modes accepted by POST /push. const ( + pushProviderAPNs = "apns" + pushProviderFCM = "fcm" + // pushModeAlert is the legacy encrypted-banner path: the desktop ships an // opaque E2E-encrypted blob and the iOS Notification Service Extension // decrypts it before the banner is shown. This is the default when @@ -96,6 +99,7 @@ const ( // `liveactivity` and `background` paths, `apns_payload` is the complete APNs // JSON payload (`{"aps": {…}, …}`) built by the desktop and forwarded verbatim. type PushRequest struct { + Provider string `json:"provider,omitempty"` DeviceToken string `json:"device_token"` EncryptedAlertB64 string `json:"encrypted_alert,omitempty"` Category string `json:"category,omitempty"` @@ -121,16 +125,12 @@ type PushRequest struct { // device. Only Live Activity content-state is unencrypted (ActivityKit // consumes it directly); a future hardening pass should require a signed // sender token. -func pushHandler(sender *PushSender) http.HandlerFunc { +func pushHandler(apnsSender *PushSender, fcmSender *FCMSender) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if sender == nil { - http.Error(w, "apns disabled on this relay", http.StatusServiceUnavailable) - return - } body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) if err != nil { http.Error(w, "read body", http.StatusBadRequest) @@ -146,11 +146,32 @@ func pushHandler(sender *PushSender) http.HandlerFunc { return } + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + if provider == "" { + provider = pushProviderAPNs + } + if provider == pushProviderFCM { + if fcmSender == nil { + http.Error(w, "fcm disabled on this relay", http.StatusServiceUnavailable) + return + } + sendFCMResponse(w, fcmSender, &req) + return + } + if provider != pushProviderAPNs { + http.Error(w, "unknown push provider", http.StatusBadRequest) + return + } + if apnsSender == nil { + http.Error(w, "apns disabled on this relay", http.StatusServiceUnavailable) + return + } + mode := req.PushType if mode == "" { mode = pushModeAlert } - environment, err := sender.environmentForRequest(&req) + environment, err := apnsSender.environmentForRequest(&req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -159,11 +180,11 @@ func pushHandler(sender *PushSender) http.HandlerFunc { var notif *apns2.Notification switch mode { case pushModeAlert: - notif, err = buildAlertNotification(sender, &req) + notif, err = buildAlertNotification(apnsSender, &req) case pushModeLiveActivity: - notif, err = buildRawNotification(sender, &req, apns2.PushTypeLiveActivity, apns2.PriorityHigh, true) + notif, err = buildRawNotification(apnsSender, &req, apns2.PushTypeLiveActivity, apns2.PriorityHigh, true) case pushModeBackground: - notif, err = buildRawNotification(sender, &req, apns2.PushTypeBackground, apns2.PriorityLow, false) + notif, err = buildRawNotification(apnsSender, &req, apns2.PushTypeBackground, apns2.PriorityLow, false) default: http.Error(w, "unknown push_type", http.StatusBadRequest) return @@ -179,7 +200,7 @@ func pushHandler(sender *PushSender) http.HandlerFunc { mode, environment, short(req.DeviceToken), req.Category, req.CollapseID, len(payloadBytes), ) - res, err := sender.clientForEnvironment(environment).Push(notif) + res, err := apnsSender.clientForEnvironment(environment).Push(notif) if err != nil { log.Printf( "apns push transport error: %v mode=%s environment=%s device=%s category=%q", @@ -200,6 +221,7 @@ func pushHandler(sender *PushSender) http.HandlerFunc { ) } resp := map[string]any{ + "provider": pushProviderAPNs, "status_code": res.StatusCode, "reason": res.Reason, "apns_id": res.ApnsID, diff --git a/scripts/ci/write-firebase-config.sh b/scripts/ci/write-firebase-config.sh new file mode 100755 index 00000000..df826514 --- /dev/null +++ b/scripts/ci/write-firebase-config.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Decode the Firebase config files from CI secrets into the locations the +# respective targets expect them. Each input is optional — missing variables +# simply skip that platform, so a workflow that only builds one app doesn't +# need every secret defined. +# +# Required env (any/all): +# FIREBASE_MACOS_B64 -> RxCode/GoogleService-Info.plist +# FIREBASE_IOS_B64 -> RxCodeMobile/GoogleService-Info.plist +# FIREBASE_ANDROID_B64 -> RxCodeAndroid/app/google-services.json + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +decode_to() { + local var_name="$1" + local out_path="$2" + local b64="${!var_name:-}" + if [[ -z "$b64" ]]; then + echo "[firebase] $var_name not set; skipping $out_path" + return + fi + mkdir -p "$(dirname "$out_path")" + printf '%s' "$b64" | base64 --decode > "$out_path" + echo "[firebase] wrote $out_path ($(wc -c < "$out_path") bytes)" +} + +decode_to FIREBASE_MACOS_B64 "$REPO_ROOT/RxCode/GoogleService-Info.plist" +decode_to FIREBASE_IOS_B64 "$REPO_ROOT/RxCodeMobile/GoogleService-Info.plist" +decode_to FIREBASE_ANDROID_B64 "$REPO_ROOT/RxCodeAndroid/app/google-services.json" From b3dc333f2fa3207e21d168dcb3281b3a44afae4f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 01:34:09 +0800 Subject: [PATCH 2/2] feat: add google analytics to iOS app --- .../Utilities/AnalyticsService.swift | 50 +++++++++++++++++++ RxCode/App/AppState+Project.swift | 4 ++ RxCode/App/FirebaseBootstrap.swift | 27 +++++++--- RxCode/Views/Inspector/ChangesView.swift | 8 ++- .../Views/Inspector/ThisThreadDiffView.swift | 6 +++ .../RunProfile/RunProfileDetailForm.swift | 6 +++ RxCode/Views/Settings/MobileSettingsTab.swift | 22 ++++++-- RxCode/Views/Sidebar/BriefingView.swift | 3 ++ .../java/app/rxlab/rxcode/MainActivity.kt | 18 ++++++- .../push/RxCodeFirebaseMessagingService.kt | 14 ++++-- .../app/rxlab/rxcode/state/MobileAppState.kt | 25 +++++++++- .../app/rxlab/rxcode/state/MobileState.kt | 8 +++ .../java/app/rxlab/rxcode/ui/RxCodeApp.kt | 11 ++++ RxCodeMobile/AppDelegate.swift | 1 + RxCodeMobile/FirebaseBootstrap.swift | 27 +++++++--- RxCodeMobile/RxCodeMobileApp.swift | 1 - .../Views/MobileBriefingDetailView.swift | 16 +++++- RxCodeMobile/Views/MobileBriefingView.swift | 3 ++ RxCodeMobile/Views/MobileChatView.swift | 3 ++ .../Views/MobileInAppBrowserView.swift | 5 ++ .../Views/MobileRunProfileEditorView.swift | 6 +++ RxCodeMobile/Views/NewThreadSheet.swift | 4 ++ RxCodeMobile/Views/ProjectsSidebar.swift | 9 ++++ RxCodeMobile/Views/ThreadChangesSheet.swift | 5 ++ 24 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift diff --git a/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift b/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift new file mode 100644 index 00000000..7ac87852 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Lightweight, transport-agnostic analytics dispatch shared by macOS, iOS, and +/// any future hosts. App targets install a handler at launch (typically wired +/// to Firebase Analytics) and view code calls `AnalyticsService.shared.log(_:)` +/// without taking a Firebase dependency on `RxCodeCore`. +public final class AnalyticsService: @unchecked Sendable { + public static let shared = AnalyticsService() + + public typealias Handler = (_ name: String, _ parameters: [String: Any]?) -> Void + + private let queue = DispatchQueue(label: "com.rxlab.rxcode.analytics") + private var handler: Handler? + + private init() {} + + public func install(handler: @escaping Handler) { + queue.sync { self.handler = handler } + } + + public func log(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + log(name: event.rawValue, parameters: parameters) + } + + public func log(name: String, parameters: [String: Any]? = nil) { + let handler = queue.sync { self.handler } + handler?(name, parameters) + } +} + +/// Canonical names for the custom events emitted by the apps. Keeping them in +/// one enum avoids drift between platforms and keeps Firebase dashboards tidy. +public enum AnalyticsEvent: String, Sendable { + // Navigation + case briefingListOpened = "briefing_list_opened" + case briefingDetailOpened = "briefing_detail_opened" + case threadOpened = "thread_opened" + case threadCreated = "thread_created" + case projectOpened = "project_opened" + + // Feature surfaces + case diffViewOpened = "diff_view_opened" + case runProfileEditorOpened = "run_profile_editor_opened" + case runProfileExecuted = "run_profile_executed" + case inAppBrowserOpened = "in_app_browser_opened" + + // Misc + case settingsOpened = "settings_opened" + case newProjectStarted = "new_project_started" +} diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 4492f194..92a0c440 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -22,6 +22,10 @@ extension AppState { func selectProject(_ project: Project, in window: WindowState) { guard window.selectedProject?.id != project.id else { return } + AnalyticsService.shared.log(.projectOpened, parameters: [ + "project_id": project.id.uuidString, + ]) + saveDraft(in: window) saveQueue(in: window) diff --git a/RxCode/App/FirebaseBootstrap.swift b/RxCode/App/FirebaseBootstrap.swift index 9e499bca..06f1affa 100644 --- a/RxCode/App/FirebaseBootstrap.swift +++ b/RxCode/App/FirebaseBootstrap.swift @@ -1,19 +1,34 @@ +import FirebaseAnalytics import FirebaseCore import FirebaseCrashlytics import Foundation +import RxCodeCore import os.log enum FirebaseBootstrap { private static let logger = Logger(subsystem: "com.idealapp.RxCode", category: "Firebase") static func configure() { - guard FirebaseApp.app() == nil else { return } - guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { - logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") - return + if FirebaseApp.app() == nil { + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") + return + } + FirebaseApp.configure() + logger.info("Firebase configured") } - FirebaseApp.configure() + // Quiet Firebase's own console chatter. The startup I-COR000003 / + // I-SES000000 pair is emitted from framework +load methods that run + // before any app code, so it can't be suppressed; this at least keeps + // the rest of the session clean. + FirebaseConfiguration.shared.setLoggerLevel(.error) Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) - logger.info("Firebase configured") + installAnalyticsHandler() + } + + private static func installAnalyticsHandler() { + AnalyticsService.shared.install { name, parameters in + Analytics.logEvent(name, parameters: parameters) + } } } diff --git a/RxCode/Views/Inspector/ChangesView.swift b/RxCode/Views/Inspector/ChangesView.swift index 07548596..e0b96942 100644 --- a/RxCode/Views/Inspector/ChangesView.swift +++ b/RxCode/Views/Inspector/ChangesView.swift @@ -40,7 +40,13 @@ struct ChangesView: View { .onChange(of: appState.isStreaming(in: windowState)) { old, new in if old && !new { triggerRefresh(at: project.path) } } - .onAppear { startWatching(at: project.path) } + .onAppear { + startWatching(at: project.path) + AnalyticsService.shared.log(.diffViewOpened, parameters: [ + "project_id": project.id.uuidString, + "surface": "inspector_changes", + ]) + } .onDisappear { stopWatching() } .onChange(of: project.path) { _, newPath in Task { @MainActor in diff --git a/RxCode/Views/Inspector/ThisThreadDiffView.swift b/RxCode/Views/Inspector/ThisThreadDiffView.swift index 4acea32b..370ec8b5 100644 --- a/RxCode/Views/Inspector/ThisThreadDiffView.swift +++ b/RxCode/Views/Inspector/ThisThreadDiffView.swift @@ -30,6 +30,12 @@ struct ThisThreadDiffView: View { } .padding(12) } + .onAppear { + AnalyticsService.shared.log(.diffViewOpened, parameters: [ + "surface": "this_thread", + "file_count": summaries.count, + ]) + } } } } diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm.swift b/RxCode/Views/RunProfile/RunProfileDetailForm.swift index 430d095b..85068a7e 100644 --- a/RxCode/Views/RunProfile/RunProfileDetailForm.swift +++ b/RxCode/Views/RunProfile/RunProfileDetailForm.swift @@ -59,6 +59,12 @@ struct RunProfileDetailForm: View { ) } .formStyle(.grouped) + .onAppear { + AnalyticsService.shared.log(.runProfileEditorOpened, parameters: [ + "project_id": project.id.uuidString, + "profile_type": profile.type.rawValue, + ]) + } } // MARK: - Command sections diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index e7d24f44..d9618e65 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -254,7 +254,7 @@ struct MobileSettingsTab: View { private func pairedRow(_ device: PairedDevice) -> some View { HStack { - Image(systemName: device.platform.lowercased().contains("ipad") ? "ipad" : "iphone") + Image(systemName: Self.deviceIconName(for: device)) .font(.title2) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { @@ -264,11 +264,11 @@ struct MobileSettingsTab: View { onlineStatePill(for: device) } HStack(spacing: 6) { - if let token = device.apnsToken, !token.isEmpty { + if let token = MobileSyncService.pushToken(for: device), !token.isEmpty { Label("Push", systemImage: "bell.fill") .font(.caption) .foregroundStyle(.green) - if let environment = apnsEnvironmentLabel(for: device) { + if let environment = pushChannelLabel(for: device) { Text("• \(environment)") .font(.caption) .foregroundStyle(.secondary) @@ -381,6 +381,22 @@ struct MobileSettingsTab: View { return "Send a push notification to \(device.displayName)." } + private static func deviceIconName(for device: PairedDevice) -> String { + let platform = device.platform.lowercased() + if platform.contains("ipad") { return "ipad" } + if platform.contains("android") { return "candybarphone" } + return "iphone" + } + + /// Provider-aware suffix shown next to the green "Push" badge. APNs shows + /// sandbox/production; FCM shows "FCM" so it's clear how the push lands. + private func pushChannelLabel(for device: PairedDevice) -> String? { + switch MobileSyncService.pushProvider(for: device) { + case "fcm": return "FCM" + default: return apnsEnvironmentLabel(for: device) + } + } + private func apnsEnvironmentLabel(for device: PairedDevice) -> String? { guard let raw = device.apnsEnvironment? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 8ac8c880..321eff42 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -127,6 +127,9 @@ struct BriefingView: View { .task(id: projectPathsKey) { await refreshCurrentBranches() } + .onAppear { + AnalyticsService.shared.log(.briefingListOpened) + } } /// True when there is at least one briefing or thread summary persisted, regardless diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt index bda0328e..4324099c 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import app.rxlab.rxcode.pairing.PairingToken +import app.rxlab.rxcode.push.RxCodeFirebaseMessagingService import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.ui.RxCodeApp import app.rxlab.rxcode.ui.theme.RxCodeTheme @@ -34,13 +35,17 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { private var pendingDeeplink by mutableStateOf(null) + private var pendingNotificationSessionID by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Log.w(TAG, "MainActivity.onCreate data=${intent?.dataString?.let { "present" } ?: "none"}") + Log.w(TAG, "MainActivity.onCreate data=${intent?.dataString?.let { "present" } ?: "none"} notifSession=${intent?.getStringExtra(RxCodeFirebaseMessagingService.EXTRA_SESSION_ID)?.let { "present" } ?: "none"}") enableEdgeToEdge() requestNotificationPermissionIfNeeded() pendingDeeplink = intent?.dataString + pendingNotificationSessionID = intent + ?.getStringExtra(RxCodeFirebaseMessagingService.EXTRA_SESSION_ID) + ?.takeIf { it.isNotBlank() } setContent { RxCodeTheme { val vm: MobileAppState = hiltViewModel() @@ -64,6 +69,12 @@ class MainActivity : ComponentActivity() { pendingDeeplink = null } } + LaunchedEffect(pendingNotificationSessionID) { + pendingNotificationSessionID?.let { sid -> + vm.openThreadFromNotification(sid) + pendingNotificationSessionID = null + } + } Box( modifier = Modifier .fillMaxSize() @@ -77,8 +88,11 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - Log.w(TAG, "MainActivity.onNewIntent data=${intent.dataString?.let { "present" } ?: "none"}") + Log.w(TAG, "MainActivity.onNewIntent data=${intent.dataString?.let { "present" } ?: "none"} notifSession=${intent.getStringExtra(RxCodeFirebaseMessagingService.EXTRA_SESSION_ID)?.let { "present" } ?: "none"}") intent.dataString?.let { pendingDeeplink = it } + intent.getStringExtra(RxCodeFirebaseMessagingService.EXTRA_SESSION_ID) + ?.takeIf { it.isNotBlank() } + ?.let { pendingNotificationSessionID = it } } private fun requestNotificationPermissionIfNeeded() { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt index 1fc6369d..fe78f03e 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/push/RxCodeFirebaseMessagingService.kt @@ -78,12 +78,18 @@ class RxCodeFirebaseMessagingService : FirebaseMessagingService() { @SuppressLint("MissingPermission") private fun showNotification(alert: AlertPlaintext) { ensureChannel() + val notifID = notificationID(alert) val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + alert.sessionID?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_SESSION_ID, it) } + alert.projectID?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_PROJECT_ID, it) } } + // Use the per-alert notif id as the request code so notifications for + // different threads each get their own PendingIntent (and don't share + // extras via FLAG_UPDATE_CURRENT). val pendingIntent = PendingIntent.getActivity( this, - 0, + notifID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -97,7 +103,7 @@ class RxCodeFirebaseMessagingService : FirebaseMessagingService() { .setPriority(NotificationCompat.PRIORITY_HIGH) .build() try { - NotificationManagerCompat.from(this).notify(notificationID(alert), notification) + NotificationManagerCompat.from(this).notify(notifID, notification) } catch (security: SecurityException) { Log.w(TAG, "notification permission missing; dropping FCM alert") } @@ -119,7 +125,9 @@ class RxCodeFirebaseMessagingService : FirebaseMessagingService() { private fun notificationID(alert: AlertPlaintext): Int = (alert.sessionID ?: alert.projectID ?: alert.kind ?: alert.title).hashCode() - private companion object { + companion object { + const val EXTRA_SESSION_ID = "app.rxlab.rxcode.extra.SESSION_ID" + const val EXTRA_PROJECT_ID = "app.rxlab.rxcode.extra.PROJECT_ID" private const val TAG = "RxCodeFCM" private const val CHANNEL_ID = "rxcode_notifications" } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index f12551e4..0e04e2bf 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -83,7 +83,13 @@ class MobileAppState @Inject constructor( // first inbound envelope decrypts cleanly. _state.value.pairedDesktops.forEach { client.addPeer(it.pubkeyHex) } client.start() - if (_state.value.isPaired) requestSnapshot("client_start") + if (_state.value.isPaired) { + requestSnapshot("client_start") + // Re-publish the FCM token on every cold start. Existing pairings + // made before this device had Firebase wired up never delivered + // a token, so the desktop shows "No push" forever otherwise. + refreshAndReportFcmToken("client_start") + } } } @@ -382,6 +388,23 @@ class MobileAppState @Inject constructor( } } + /** + * Buffers a session id from a tapped FCM notification. `RxCodeApp` + * observes [MobileState.pendingNotificationSessionID] and routes the UI + * (switch to Projects tab + select the session) once the app has finished + * splash/onboarding gating. Buffered values survive cold launch because + * `MainActivity` re-posts them on every `onCreate`/`onNewIntent`. + */ + fun openThreadFromNotification(sessionId: String) { + if (sessionId.isBlank()) return + Log.i(TAG, "notification tap -> open thread sessionID=${sessionId.take(8)}") + _state.update { it.copy(pendingNotificationSessionID = sessionId) } + } + + fun consumePendingNotificationDeepLink() { + _state.update { it.copy(pendingNotificationSessionID = null) } + } + fun selectSession(sessionId: String?) { val resolvedSessionId = sessionId?.let { _state.value.resolveSessionId(it) } _state.update { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt index 47512e74..ecf283a4 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt @@ -72,6 +72,14 @@ data class MobileState( val hasReceivedInitialSnapshot: Boolean = false, val lastError: String? = null, + + /** + * Set by [MobileAppState.openThreadFromNotification] when an FCM + * notification tap should route the UI to a specific thread. Consumed by + * `RxCodeApp`, which selects the session and switches to the Projects tab, + * then clears it via [MobileAppState.consumePendingNotificationDeepLink]. + */ + val pendingNotificationSessionID: String? = null, ) { val isPaired: Boolean get() = activeDesktopPubkey.isNotEmpty() diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt index 7794071a..66638afe 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt @@ -76,6 +76,17 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { var currentTab by rememberSaveable { mutableStateOf(RootTab.Briefing.name) } val tab = RootTab.valueOf(currentTab) + // FCM notification tap: select the target session and switch to Projects + // (where ProjectsPaneScreen reacts to `activeSessionID` and pushes the + // chat detail). Buffered in state by MobileAppState so we route the UI + // even if the tap arrived during the splash/onboarding gate above. + LaunchedEffect(state.pendingNotificationSessionID) { + val sid = state.pendingNotificationSessionID ?: return@LaunchedEffect + viewModel.selectSession(sid) + currentTab = RootTab.Projects.name + viewModel.consumePendingNotificationDeepLink() + } + NavigationSuiteScaffold( navigationSuiteItems = { RootTab.allTabs.forEach { t -> diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index d5eb4257..53f977be 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -22,6 +22,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + FirebaseBootstrap.configure() UNUserNotificationCenter.current().delegate = self logger.info("[APNs] notification delegate installed") Task { diff --git a/RxCodeMobile/FirebaseBootstrap.swift b/RxCodeMobile/FirebaseBootstrap.swift index 9121ae90..98075b85 100644 --- a/RxCodeMobile/FirebaseBootstrap.swift +++ b/RxCodeMobile/FirebaseBootstrap.swift @@ -1,19 +1,34 @@ +import FirebaseAnalytics import FirebaseCore import FirebaseCrashlytics import Foundation +import RxCodeCore import os.log enum FirebaseBootstrap { private static let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "Firebase") static func configure() { - guard FirebaseApp.app() == nil else { return } - guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { - logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") - return + if FirebaseApp.app() == nil { + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + logger.warning("GoogleService-Info.plist not bundled — Firebase disabled") + return + } + FirebaseApp.configure() + logger.info("Firebase configured") } - FirebaseApp.configure() + // Quiet Firebase's own console chatter. The startup I-COR000003 / + // I-SES000000 pair is emitted from framework +load methods that run + // before any app code, so it can't be suppressed; this at least keeps + // the rest of the session clean. + FirebaseConfiguration.shared.setLoggerLevel(.error) Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) - logger.info("Firebase configured") + installAnalyticsHandler() + } + + private static func installAnalyticsHandler() { + AnalyticsService.shared.install { name, parameters in + Analytics.logEvent(name, parameters: parameters) + } } } diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index e14eb455..c090a8b9 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -11,7 +11,6 @@ struct RxCodeMobileApp: App { @Environment(\.scenePhase) private var scenePhase init() { - FirebaseBootstrap.configure() try? Tips.configure([ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault), diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index 23b97e13..1cbf1e60 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -13,7 +13,7 @@ struct MobileBriefingDetailView: View { @State private var isInitializingGit = false var body: some View { - ScrollView { + ScrollView(.vertical) { VStack(alignment: .leading, spacing: 24) { headerCard summaryCard @@ -23,6 +23,7 @@ struct MobileBriefingDetailView: View { .padding(.horizontal, 16) .padding(.vertical, 20) } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) .scrollContentBackground(.hidden) .accessibilityIdentifier("briefing-detail-screen") .navigationTitle(projectName) @@ -52,6 +53,12 @@ struct MobileBriefingDetailView: View { .refreshable { await state.refreshSnapshot() } + .onAppear { + AnalyticsService.shared.log(.briefingDetailOpened, parameters: [ + "project_id": groupKey.projectId.uuidString, + "branch": groupKey.branch, + ]) + } } private var group: GroupedBriefing? { @@ -102,6 +109,9 @@ struct MobileBriefingDetailView: View { Text(projectName) .font(.title3.weight(.semibold)) .foregroundStyle(.primary) + .lineLimit(2) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 12) { // Branch chip — falls back to an Init Git action when the @@ -179,6 +189,8 @@ struct MobileBriefingDetailView: View { color: .primary, lineSpacing: 4 ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } else { HStack(spacing: 10) { Image(systemName: "text.justify.leading") @@ -328,6 +340,8 @@ struct MobileBriefingThreadCard: View { lineSpacing: 2, maximumNumberOfLines: 3 ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } if isStreaming { diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 80032c17..70ed02f6 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -83,6 +83,9 @@ struct MobileBriefingView: View { .refreshable { await state.refreshSnapshot() } + .onAppear { + AnalyticsService.shared.log(.briefingListOpened) + } .navigationDestination(for: BriefingGroupKey.self) { key in MobileBriefingDetailView(groupKey: key, onOpenSession: onOpenSession) } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 59bf3c13..7103b1bb 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -138,6 +138,9 @@ struct MobileChatView: View { .navigationTitle(title) .toolbar { threadActionsToolbar } .task(id: sessionID) { + AnalyticsService.shared.log(.threadOpened, parameters: [ + "session_id": sessionID, + ]) await runThreadLoadingGate() } .task(id: resolvedSessionID) { diff --git a/RxCodeMobile/Views/MobileInAppBrowserView.swift b/RxCodeMobile/Views/MobileInAppBrowserView.swift index cc7f53db..115c1f83 100644 --- a/RxCodeMobile/Views/MobileInAppBrowserView.swift +++ b/RxCodeMobile/Views/MobileInAppBrowserView.swift @@ -1,6 +1,7 @@ import Foundation import Network import os.log +import RxCodeCore import RxCodeSync import SwiftUI import WebKit @@ -163,6 +164,10 @@ struct MobileInAppBrowserView: View { await observePageNavigations() } .onAppear { + AnalyticsService.shared.log(.inAppBrowserOpened, parameters: [ + "has_proxy": proxyInfo != nil, + "host": loadedURL?.host ?? "", + ]) guard let loadedURL, webPage.url == nil else { return } logger.info("[WebBrowserSync] webview initial load url=\(loadedURL.absoluteString, privacy: .public) hasProxy=\((proxyInfo != nil), privacy: .public)") webPage.load(loadedURL) diff --git a/RxCodeMobile/Views/MobileRunProfileEditorView.swift b/RxCodeMobile/Views/MobileRunProfileEditorView.swift index 41435a0b..40bc7d45 100644 --- a/RxCodeMobile/Views/MobileRunProfileEditorView.swift +++ b/RxCodeMobile/Views/MobileRunProfileEditorView.swift @@ -87,6 +87,12 @@ struct MobileRunProfileEditorView: View { folderPickerSheet(for: pick) .mobileSheetPresentation() } + .onAppear { + AnalyticsService.shared.log(.runProfileEditorOpened, parameters: [ + "project_id": projectID.uuidString, + "profile_type": String(describing: draft.type), + ]) + } } /// Builds the remote file-sheet for whichever field requested it. The diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 9588f5fa..4ef94c11 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -232,6 +232,10 @@ struct NewThreadSheet: View { .max(by: { $0.updatedAt < $1.updatedAt }) guard let newest else { return } awaitingFromSessionIDs = nil + AnalyticsService.shared.log(.threadCreated, parameters: [ + "project_id": projectID.uuidString, + "session_id": newest.id, + ]) onSessionCreated(newest.id) dismiss() } diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 497ef18d..8e4a27a9 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -59,6 +59,15 @@ struct ProjectsSidebar: View { guard let newValue else { return } selected = newValue showingBriefing = false + AnalyticsService.shared.log(.newProjectStarted, parameters: [ + "project_id": newValue.uuidString, + ]) + } + .onChange(of: selected) { _, newValue in + guard let newValue else { return } + AnalyticsService.shared.log(.projectOpened, parameters: [ + "project_id": newValue.uuidString, + ]) } } diff --git a/RxCodeMobile/Views/ThreadChangesSheet.swift b/RxCodeMobile/Views/ThreadChangesSheet.swift index 851159de..86fed0b1 100644 --- a/RxCodeMobile/Views/ThreadChangesSheet.swift +++ b/RxCodeMobile/Views/ThreadChangesSheet.swift @@ -52,6 +52,11 @@ struct ThreadChangesSheet: View { } } .task { await load() } + .onAppear { + AnalyticsService.shared.log(.diffViewOpened, parameters: [ + "session_id": sessionID, + ]) + } } private func load() async {