17 Commits

Author SHA1 Message Date
b3a55172a2 docs(apple): document Mac target in screenshot studio README 2026-08-09 18:20:53 +02:00
5e8ba09fa5 chore: gitignore local compliance material 2026-08-09 18:20:53 +02:00
7aabfdf065 feat(apple): add MacBook screenshot studio target
Add a Mac (16:10, 2880x1800) platform to the screenshot studio: a
MacBook .usdz model, native-app capture path in capture.sh (no simulator),
and all four marketing layouts. Fix the screen texturing so the capture
fills the display exactly — plain [0,1] planar UVs with a thin black matte
baked into the shot for the rounded-corner margin (replacing the UV
overscan that smeared the edge). Add a horizontal encryption-flow mode
(beams/stream) for the side-by-side laptops in stay-private.
2026-08-09 18:20:53 +02:00
98661cef15 feat(studio): iPad screenshot support + cleanup
Generalize the studio into a Platform/DeviceModel abstraction so it renders
both iPhone and iPad sets from one code path.

- Platform: per-target canvas (iPad 2064x2752), 3D model, capture sim, and
  output subfolder (iPad -> <Language>/iPad/). PLATFORM=iphone|ipad.
- DeviceModel: screen material, orientation (yaw) fix, optional glass mesh,
  graphite recolor — the per-model quirks the renderer needs.
- SceneKit renderer: auto-generates planar UVs for screen meshes that ship
  without them; textures every material matching the screen name; zFar scaled
  for large-unit models; body recolor skips the real screen material.
- iPad uses a CC BY 4.0 model (commercial-safe) with real bezel geometry;
  first-pass iPad layouts for all four screens. capture.sh is PLATFORM-aware.

Remove now-dead code: the 2D mockup fallback (ClipDeviceRenderer,
MockupGeometry), the orbit ring and binary-tunnel (superseded by the route arc
and the encryption-flow), unused DeviceModel UV/inset workarounds; refresh the
README and .gitignore to match.
2026-08-09 18:20:53 +02:00
4bac61e505 feat(studio): native SwiftUI + SceneKit App Store screenshot pipeline
Replace the Typst/ImageMagick studio with a SwiftPM executable that composes
each marketing screen as a SwiftUI view and renders it headlessly via
ImageRenderer to exact 1284x2778 PNGs.

- Devices are a real iPhone 17 Pro Max .usdz textured + posed in SceneKit
  (graphite body, studio IBL); screen curvature/bezel are the model's geometry.
- Per-screen layouts (pose, globe, route arc, encryption flow) in ScreenSpec.
- send-anywhere: globe + Paris->LA route arc; uses the app's share screen.
- stay-private: converging beams -> glowing padlock -> binary protection stream,
  with localized CHIFFREMENT/PROTECTION banners and an auto-sizing header panel.
- Captions auto-shrink per locale (ViewThatFits) so long translations never clip.
- Screenshots are transient (generated/shots, git-ignored), captured per locale;
  generate.sh does capture + composite in one shot.
- Commit the .usdz model (CC BY 4.0, see assets/ATTRIBUTION.md) and globe asset.
- Remove the superseded Typst pipeline (screens.typ, build.sh, frame.sh, warp.sh).
2026-08-09 18:20:52 +02:00
06d33361c2 chore(apple): add screenshot studio 2026-08-09 18:20:52 +02:00
Hammed Abass
387298d137 Merge pull request #42 from sudosylabs/feat/android-background-sharing
fix(android): keep active shares alive in background
2026-08-09 15:17:06 +01:00
3118afdba0 fix(android): keep active shares alive in background 2026-08-08 03:00:16 +02:00
Hammed Abass
e8c3eadfc8 Merge pull request #41 from sudosylabs/feat/shared-app-config
Shared app config + remove telemetry (keep bug reports)
2026-08-02 19:50:49 +02:00
877083a3ed refactor(diagnostics): remove bug report breadcrumbs 2026-08-02 19:18:18 +02:00
7cb2270d56 ci(apple): add Xcode Cloud post-clone script
Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint,
xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift)
from the matching GitHub Release asset, and generates the project via localization,
version/app config codegen and xcodegen. Rust is never built on Xcode Cloud.
2026-08-02 10:29:46 +02:00
eb8498168d fix(shared): avoid java accessor shadowing when reading app.properties
In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension
accessor, so `java.util.Properties` failed script compilation with
"Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs.
Import java.util.Properties and use it unqualified, matching the root build.
2026-08-02 10:29:45 +02:00
ac6e837560 docs: describe bug reports instead of telemetry
Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2)
and the README/apple README to reflect that only user-submitted bug reports
remain.
2026-08-02 10:03:29 +02:00
3e18378610 refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports
Remove the /v1/events and /v1/crashes routes, their normalizers and storage
paths, and simplify retention to the bugs table. Add a migration dropping the
now-unused event_batches and crashes tables, and regenerate worker types.
2026-08-02 10:03:12 +02:00
b68d338097 refactor(apple): remove diagnostics opt-in toggle, keep bug reports
Drop the Share-diagnostics preference, its Settings toggle and the
DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the
diagnostics install id used for bug-report correlation are retained.
2026-08-02 10:02:49 +02:00
b8a002a2ad refactor(shared): remove telemetry and crash reporting, keep bug reports
Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
2026-08-02 10:02:28 +02:00
232fb125d3 feat(config): shared app.properties for app-wide constants
Add a single source of truth (root app.properties) for public app-wide
constants, injected at build time on both platforms instead of hardcoding.

- Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into
  `make apple-app-config`), consumed as AppConfig.privacyPolicyURL.
- KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig),
  consumed as AppConfig.PRIVACY_POLICY_URL.

Replaces the stale hardcoded privacy-policy URL on both sides with
https://vnidrop.sudosy.fr/privacy/.

Also fix the Apple release core build: disable release LTO in build-core.sh
(Cargo forbids lto in a build-override) to avoid the proc-macro
"mis-aligned LINKEDIT string pool" corruption, so release archives are
compact instead of shipping the debug core.

Update the app icon.

Tests: shell test for the generator (escaping, missing/duplicate key),
plus XCTest and jvmTest asserting the generated value matches app.properties.
2026-08-01 19:56:07 +02:00
103 changed files with 3003 additions and 3057 deletions

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ output/
.screenshots .screenshots
apple/RELEASE-MACOS.md apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig apple/Generated/*.xcconfig
# Compliance material — kept locally, never committed
compliance/

View File

@@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk
.PHONY: format test check check-rust audit-rust test-rust test-rust-all .PHONY: format test check check-rust audit-rust test-rust test-rust-all
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core .PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate .PHONY: prepare-release check-version check-release check-localization localization localization-migrate
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
@@ -71,8 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML. check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
cd $(ROOT) && apple/scripts/tests/test-notarize.sh cd $(ROOT) && apple/scripts/tests/test-notarize.sh
cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh cd $(ROOT) && packaging/release/test-assemble-release.sh
@@ -135,7 +136,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
apple-version-config: ## Generate derived Store and Direct Apple build settings. apple-version-config: ## Generate derived Store and Direct Apple build settings.
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project. apple-app-config: ## Generate AppConfig.swift from the shared app.properties.
cd $(ROOT) && apple/scripts/generate-appconfig.sh
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project. open-apple-project: apple-project ## Generate and open the native Apple Xcode project.

View File

@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux Windows, and Linux
- Strict custom HTTPS relay profiles with safe apply and rollback - Strict custom HTTPS relay profiles with safe apply and rollback
- Opt-in diagnostics with transfer contents, invitations, and file paths - Optional user-submitted bug reports with transfer contents, invitations, and
excluded file paths excluded
## Privacy by design ## Privacy by design
- **No hosted transfer copy.** VniDrop does not upload file contents to its - **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
diagnostics service or a VniDrop storage bucket. service or a VniDrop storage bucket.
- **Encrypted in transit.** Iroh connections are authenticated and encrypted - **Encrypted in transit.** Iroh connections are authenticated and encrypted
end to end, including when a relay is needed. end to end, including when a relay is needed.
- **Local control.** Transfer history and sharing state stay on the device. - **Local control.** Transfer history and sharing state stay on the device.
- **Sensitive invitations.** An invitation can grant access, so it is - **Sensitive invitations.** An invitation can grant access, so it is
deliberately excluded from product logs and diagnostics. deliberately excluded from product logs and bug reports.
- **Explicit access.** Approval is required by default, and stopping a share - **Explicit access.** Approval is required by default, and stopping a share
removes access immediately. removes access immediately.

4
app.properties Normal file
View File

@@ -0,0 +1,4 @@
# Public, app-wide configuration shared by every platform (Apple + KMP).
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
# Injected into the apps at build time — never hardcode these values in app code.
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/

View File

@@ -114,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications: Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios` - Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
dependency); swap in `lottie-ios` if exact-parity animation is required. dependency); swap in `lottie-ios` if exact-parity animation is required.
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind - Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI a real transport lands in a later phase. There is no telemetry or crash
hides the diagnostics toggle when not compiled in. auto-reporting.
``` ```

View File

@@ -0,0 +1,229 @@
# Publier VniDrop sur TestFlight interne — guide complet
Ce guide décrit **toutes les étapes** pour compiler l'application et l'envoyer sur
**TestFlight interne** (aucune revue Apple n'est nécessaire pour les testeurs internes).
- **Bundle ID** : `com.vnidrop.app`
- **Team ID Apple** : `A8A4JSMV5D`
- **Branche à utiliser** : `feat/release-test-flight`
> ⚠️ **Point crucial** : il faut compiler avec un **Xcode de version finale (release)**,
> par exemple **Xcode 26** — **pas** une version bêta. Un envoi construit avec un Xcode
> bêta est **refusé** par App Store Connect (« Unsupported SDK or Xcode version »).
---
## 1. Prérequis
- Un Mac sous **macOS stable** (pas une bêta) avec **Xcode 26** installé.
- Un **identifiant Apple** (gratuit) — **aucun abonnement développeur payant n'est
nécessaire de votre côté**. Le propriétaire du compte vous invitera sur le sien.
- Une connexion internet.
---
## 2. Obtenir l'accès au compte développeur
Le propriétaire du compte doit vous inviter (une seule fois) :
1. Sur **App Store Connect****Utilisateurs et accès****Ajouter un utilisateur**.
2. Il saisit **votre identifiant Apple** et vous attribue le rôle **Admin**
(nécessaire pour gérer la signature) ou au minimum **App Manager**.
3. Vous recevez un e-mail d'invitation — **acceptez-le**.
Ensuite, dans **Xcode** → menu **Xcode → Settings → Accounts****+** →
connectez-vous avec **votre** identifiant Apple. L'équipe **VniDrop (A8A4JSMV5D)**
doit apparaître.
---
## 3. Installer les outils
Dans le Terminal :
```bash
# Homebrew (si absent) : voir https://brew.sh
# Outils de génération de projet et de qualité de code
brew install xcodegen swiftlint
# Rust (pour compiler le cœur natif)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
Installez aussi les **Command Line Tools** de Xcode si demandé :
```bash
xcode-select --install
```
---
## 4. Récupérer le projet
```bash
git clone <URL_DU_DEPOT> vnidrop
cd vnidrop
git checkout feat/release-test-flight
```
> Le fichier `.xcodeproj`, le `Local.xcconfig` et le framework compilé ne sont **pas**
> versionnés : ils seront (re)générés localement aux étapes suivantes.
---
## 5. Configurer la signature
Créez le fichier **`apple/Local.xcconfig`** (ignoré par git) avec ce contenu :
```
DEVELOPMENT_TEAM = A8A4JSMV5D
CODE_SIGN_STYLE = Automatic
CODE_SIGNING_ALLOWED = YES
```
Cela active la signature sur cette machine sans modifier la configuration partagée
(qui reste non signée pour l'intégration continue).
---
## 6. Compiler le cœur Rust
Depuis la racine du dépôt :
```bash
apple/scripts/build-core.sh
```
Cela produit `apple/VnidropCore/vnidrop.xcframework` (avec la tranche **arm64 device**
requise pour TestFlight) et les liaisons Swift.
- La compilation **debug** (par défaut) convient parfaitement pour TestFlight.
- Si vous voulez une compilation **release** : `apple/scripts/build-core.sh release`.
Sur macOS stable, cela devrait fonctionner. En cas d'erreur `can't find crate`
(dylibs de macros corrompus), nettoyez et repassez en debug :
```bash
cargo clean
apple/scripts/build-core.sh
```
---
## 7. Incrémenter le numéro de build
Chaque envoi doit avoir un **numéro de build unique et supérieur** au précédent.
Dans **`apple/project.yml`**, cherchez `CURRENT_PROJECT_VERSION` et mettez **`4`**
(les numéros 1 à 3 ont déjà été utilisés) :
```yaml
CURRENT_PROJECT_VERSION: "4"
```
> Pour tout envoi ultérieur, augmentez encore ce nombre (5, 6, …).
---
## 8. Générer le projet Xcode
```bash
cd apple
xcodegen generate
```
Cela crée `apple/VniDrop.xcodeproj` à partir de `project.yml` et du `Local.xcconfig`.
---
## 9. Archiver dans Xcode
1. Ouvrez **`apple/VniDrop.xcodeproj`** dans **Xcode 26**.
2. En haut, sélectionnez le schéma **VniDrop** et la destination
**Any iOS Device (arm64)** (surtout **pas** un simulateur).
3. Menu **Product → Archive**.
4. À la fin, la fenêtre **Organizer** s'ouvre avec votre archive.
---
## 10. Envoyer sur App Store Connect
1. Dans l'**Organizer**, sélectionnez l'archive → **Distribute App**.
2. Choisissez **App Store Connect** → **Upload**.
3. Laissez les options par défaut (**signature automatique**) → **Upload**.
4. La question sur le chiffrement **ne sera pas posée** (déjà réglée dans l'Info.plist).
Patientez quelques minutes : le build apparaît ensuite dans App Store Connect avec le
statut **« En cours de traitement »**, puis devient disponible.
---
## 11. Publier sur TestFlight interne (sans revue)
1. Sur **App Store Connect** → l'app **VniDrop** → onglet **TestFlight**.
2. Attendez que le build passe de **« En cours de traitement »** à disponible.
3. Section **Tests internes** → créez un groupe (ou utilisez celui par défaut) →
ajoutez les **testeurs internes** (ce sont des utilisateurs de l'équipe App Store
Connect ; le propriétaire les ajoute via **Utilisateurs et accès** si besoin).
4. Activez le build pour le groupe.
5. Les testeurs reçoivent un e-mail, installent l'app **TestFlight**, acceptent, puis
installent VniDrop. **Aucune revue Apple** n'est requise pour les tests internes.
---
## 12. Solution de repli pour la signature
Si, à l'étape 9/10, Xcode **refuse de créer un certificat de distribution**
automatiquement (limitation possible des comptes individuels), le **propriétaire du
compte** doit fournir les éléments de signature :
1. Portail développeur → **Certificates** → créer un certificat **Apple Distribution**,
puis l'**exporter en `.p12`** (avec la clé privée) depuis le Trousseau (Keychain).
2. **Profiles** → créer un profil de provisioning **App Store** pour `com.vnidrop.app`.
3. Vous transmet le `.p12` (+ son mot de passe) et le profil.
De votre côté :
- Importez le `.p12` dans le **Trousseau** (double-clic).
- Dans Xcode, désactivez la signature automatique et sélectionnez la signature
**manuelle** avec ce profil, puis reprenez l'archivage (étape 9).
> 🔒 Un `.p12` de distribution permet de signer des apps au nom du propriétaire :
> à n'utiliser qu'entre personnes de confiance. Le certificat peut être révoqué ensuite.
---
## 13. Dépannage
- **« Unsupported SDK or Xcode version »** → vous compilez avec un Xcode **bêta**.
Utilisez **Xcode 26 (release)**.
- **Échec de la phase SwiftLint** → `brew install swiftlint` (obligatoire, la build
échoue sinon).
- **Numéro de build déjà utilisé** → augmentez `CURRENT_PROJECT_VERSION` puis
`xcodegen generate` à nouveau.
- **L'app n'apparaît pas dans TestFlight** → attendez la fin du « traitement » ; la
conformité export est déjà déclarée, aucune action supplémentaire.
- **Le même Xcode pour le cœur Rust et l'archivage n'est pas obligatoire** (le cœur est
du Rust), mais l'**archivage** doit impérativement se faire avec **Xcode 26 release**.
---
## Récapitulatif express
```bash
# 1. Outils
brew install xcodegen swiftlint
# 2. Projet
git checkout feat/release-test-flight
# 3. Signature : créer apple/Local.xcconfig (voir §5)
# 4. Cœur natif
apple/scripts/build-core.sh
# 5. Numéro de build : CURRENT_PROJECT_VERSION -> 4 dans apple/project.yml
# 6. Projet Xcode
cd apple && xcodegen generate
# 7. Xcode 26 : schéma VniDrop, destination « Any iOS Device (arm64) »,
# Product → Archive → Distribute App → App Store Connect → Upload
# 8. App Store Connect → TestFlight → Tests internes → ajouter les testeurs
```

View File

@@ -0,0 +1,39 @@
import XCTest
@testable import VniDrop
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
/// exposes the expected, well-formed values to the app.
final class AppConfigTests: XCTestCase {
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
let url = AppConfig.privacyPolicyURL
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
}
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
// Cross-check the generated constant against the single source of truth so a
// broken generator (or drift) is caught, not just a hardcoded copy.
let expected = try Self.privacyURLFromAppProperties()
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
}
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
/// from this source file's location to the repository root.
private static func privacyURLFromAppProperties() throws -> String {
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<8 {
let candidate = dir.appendingPathComponent("app.properties")
if FileManager.default.fileExists(atPath: candidate.path) {
let contents = try String(contentsOf: candidate, encoding: .utf8)
for line in contents.split(whereSeparator: \.isNewline) {
if line.hasPrefix("PRIVACY_POLICY_URL=") {
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
}
}
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
}
dir.deleteLastPathComponent()
}
throw XCTSkip("app.properties not found from \(#filePath)")
}
}

View File

@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
preferences: preferences, preferences: preferences,
notifications: LocalNotificationService(), notifications: LocalNotificationService(),
messages: UiMessageController(), messages: UiMessageController(),
bugReports: NoopBugReportService(), bugReports: NoopBugReportService()
diagnosticsIncluded: false
) )
} }

View File

@@ -0,0 +1,44 @@
import XCTest
/// Captures App Store screenshots by driving the running app and grabbing the
/// full-screen image on each tab. Run via `apple/scripts/appstore-screenshots.sh`,
/// which boots the target simulator, runs this test, and extracts the attachments
/// at the device's native resolution (e.g. 2064×2752 on the 13-inch iPad).
final class ScreenshotTests: XCTestCase {
override func setUp() {
continueAfterFailure = false
}
func testCaptureAppStoreScreenshots() {
let app = XCUIApplication()
app.launch()
// The top pill exposes the three primary tabs. Tap by accessibility label
// when available, falling back to a normalized coordinate on the pill.
let tabs: [(name: String, dx: CGFloat)] = [
("01-Send", 0.407),
("02-Receive", 0.487),
("03-Settings", 0.579),
]
for tab in tabs {
let label = String(tab.name.dropFirst(3)) // "Send" / "Receive" / "Settings"
let button = app.buttons[label]
if button.waitForExistence(timeout: 10), button.isHittable {
button.tap()
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: tab.dx, dy: 0.039)).tap()
}
// Let the tab transition and any content settle before capturing.
Thread.sleep(forTimeInterval: 1.5)
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = tab.name
attachment.lifetime = .keepAlways
add(attachment)
}
}
}

View File

@@ -7,6 +7,10 @@ import Combine
final class AppGraph: ObservableObject { final class AppGraph: ObservableObject {
let dependencies: AppDependencies let dependencies: AppDependencies
let coreRepository: CoreRepository let coreRepository: CoreRepository
/// The gateway the feature models and coordinators observe. Normally the real
/// `coreRepository`; in screenshot builds a fixture is injected so the UI shows
/// deterministic content without the Rust core (see `ScreenshotSupport`).
let gateway: CoreGateway
let visibility = AppVisibility() let visibility = AppVisibility()
let messages = UiMessageController() let messages = UiMessageController()
let preferencesRepository: AppPreferencesRepository let preferencesRepository: AppPreferencesRepository
@@ -15,27 +19,28 @@ final class AppGraph: ObservableObject {
let transferNotificationCoordinator: TransferNotificationCoordinator let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
self.dependencies = dependencies self.dependencies = dependencies
let coreRepository = coreRepository ?? CoreRepository() let coreRepository = coreRepository ?? CoreRepository()
self.coreRepository = coreRepository self.coreRepository = coreRepository
let gateway = coreGateway ?? coreRepository
self.gateway = gateway
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir) self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
self.preferencesRepository = AppPreferencesRepository( self.preferencesRepository = AppPreferencesRepository(
fallback: AppPreferencesDefaults( fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername, username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system, themeMode: .system
diagnosticsEnabled: false
) )
) )
self.approvalCoordinator = ApprovalCoordinator( self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository, repository: gateway,
notifications: dependencies.notificationService, notifications: dependencies.notificationService,
visibility: visibility, visibility: visibility,
messages: messages messages: messages
) )
self.transferNotificationCoordinator = TransferNotificationCoordinator( self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository, repository: gateway,
notifications: dependencies.notificationService, notifications: dependencies.notificationService,
visibility: visibility, visibility: visibility,
messages: messages messages: messages

View File

@@ -12,24 +12,39 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
#if DEBUG
@State private var screenshotScenario: ScreenshotScenario?
#endif
init(dependencies: AppDependencies) { init(dependencies: AppDependencies) {
#if DEBUG
let scenario = ScreenshotScenario.current
let graph = AppGraph(
dependencies: dependencies,
coreGateway: scenario.map { ScreenshotCoreGateway(scenario: $0) }
)
_screenshotScenario = State(initialValue: scenario)
#else
let graph = AppGraph(dependencies: dependencies) let graph = AppGraph(dependencies: dependencies)
#endif
_graph = StateObject(wrappedValue: graph) _graph = StateObject(wrappedValue: graph)
// The feature models observe `graph.gateway` the real core normally, or the
// injected screenshot fixture so a screenshot build never starts the core.
_appModel = StateObject(wrappedValue: AppModel( _appModel = StateObject(wrappedValue: AppModel(
environment: dependencies.environment, environment: dependencies.environment,
repository: graph.coreRepository, repository: graph.gateway,
preferences: graph.preferencesRepository, preferences: graph.preferencesRepository,
messages: graph.messages messages: graph.messages
)) ))
_sendModel = StateObject(wrappedValue: SendModel( _sendModel = StateObject(wrappedValue: SendModel(
repository: graph.coreRepository, repository: graph.gateway,
fileSystemService: dependencies.fileSystemService, fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository, preferences: graph.preferencesRepository,
filePreviewRepository: graph.filePreviewRepository, filePreviewRepository: graph.filePreviewRepository,
messages: graph.messages messages: graph.messages
)) ))
_receiveModel = StateObject(wrappedValue: ReceiveModel( _receiveModel = StateObject(wrappedValue: ReceiveModel(
repository: graph.coreRepository, repository: graph.gateway,
fileSystemService: dependencies.fileSystemService, fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository, preferences: graph.preferencesRepository,
messages: graph.messages messages: graph.messages
@@ -38,7 +53,7 @@ struct RootView: View {
environment: dependencies.environment, environment: dependencies.environment,
deviceInfoProvider: dependencies.deviceInfoProvider, deviceInfoProvider: dependencies.deviceInfoProvider,
fileSystemService: dependencies.fileSystemService, fileSystemService: dependencies.fileSystemService,
repository: graph.coreRepository, repository: graph.gateway,
preferences: graph.preferencesRepository, preferences: graph.preferencesRepository,
notifications: dependencies.notificationService, notifications: dependencies.notificationService,
messages: graph.messages, messages: graph.messages,
@@ -78,6 +93,15 @@ struct RootView: View {
} }
.platformPickers(settingsModel: settingsModel) .platformPickers(settingsModel: settingsModel)
.task { await consumeExternalInvitations() } .task { await consumeExternalInvitations() }
#if DEBUG
// Once the (fixture) core reports ready, drive the app into the target screen.
.task(id: sendModel.coreState.isInitialized) {
guard let scenario = screenshotScenario, sendModel.coreState.isInitialized else { return }
appModel.selectDestination(.send)
sendModel.openTransfer(ScreenshotCoreGateway.transferId)
if scenario == .share { sendModel.openShare() }
}
#endif
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
switch phase { switch phase {
case .active: case .active:

View File

@@ -0,0 +1,109 @@
#if DEBUG
import Foundation
import Combine
import VnidropCore
/// Which marketing screen to stage. Selected via the `-VniScreenshot <name>` launch
/// argument (read from `NSArgumentDomain`), set by the App Store screenshot UI test.
enum ScreenshotScenario: String {
case transferDetails = "transfer-details" // Send Anywhere: the detail view
case share // Share Securely: the QR / share panel
case approval // Choose Receivers: the receive-request modal
/// The active scenario for this launch, or `nil` in a normal run.
static var current: ScreenshotScenario? {
guard let raw = UserDefaults.standard.string(forKey: "VniScreenshot") else { return nil }
return ScreenshotScenario(rawValue: raw)
}
}
/// A fixture `CoreGateway` that publishes deterministic content instead of driving
/// the Rust core, so App Store screenshots are stable and localized. Only the reads
/// the screenshot screens need are meaningful; mutations are inert.
@MainActor
final class ScreenshotCoreGateway: CoreGateway {
private let scenario: ScreenshotScenario
private let subject: CurrentValueSubject<CoreState, Never>
private let signalSubject = PassthroughSubject<CoreSignal, Never>()
/// Deterministic fixture transfer shown across every scenario.
static let transferId: UInt64 = 1
private let fixtureTransfer = Transfer(
localId: "screenshot-1",
transferId: ScreenshotCoreGateway.transferId,
direction: .send,
status: .sharing,
peerId: nil,
transferName: "Transfer.MOV",
contentHash: "b1946ac92492d2347c6235b4d2611184",
fileCount: 1,
totalSize: 9_100_000,
ticket: "vnd://screenshot-demo-ticket-abcdefghijklmnopqrstuvwxyz0123456789",
accessPolicy: .requireApproval,
createdAt: 1_722_000_000,
updatedAt: 1_722_000_000
)
init(scenario: ScreenshotScenario) {
self.scenario = scenario
self.subject = CurrentValueSubject(CoreState())
}
var state: CoreState { subject.value }
var statePublisher: AnyPublisher<CoreState, Never> { subject.eraseToAnyPublisher() }
var signals: AnyPublisher<CoreSignal, Never> { signalSubject.eraseToAnyPublisher() }
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error> {
subject.value = CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "screenshot-endpoint", activeTransfers: 1, activeShares: 1),
events: [],
transfers: [fixtureTransfer],
lastShare: nil,
lastInspection: nil
)
// Nudge the approval coordinator to (re)read requests for the sharing transfer.
signalSubject.send(.approvalChanged(transferId: Self.transferId))
return .success(())
}
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
guard scenario == .approval else { return .success([]) }
return .success([
ReceiverRequestModel(
id: "screenshot-request-1",
transferId: Self.transferId,
remoteEndpointId: "k51qzi5uqu5d-screenshot-peer-endpoint-identity",
transferName: "Transfer.MOV",
receiverName: nil,
receiverDeviceName: "Mac mini",
appVersion: "1.0",
status: .requested,
reason: nil,
requestedAt: 1_722_000_000,
respondedAt: nil,
completedAt: nil
)
])
}
// MARK: - Inert mutations (screenshots never exercise these)
func shutdown() {}
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
.failure(ScreenshotGatewayError.unsupported)
}
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { .failure(ScreenshotGatewayError.unsupported) }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
func cancel(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
func delete(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
func clearReceiveHistory() async -> Result<UInt64, Error> { .success(0) }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> { .success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0)) }
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { .success(()) }
func refresh() async -> Result<Void, Error> { .success(()) }
}
private enum ScreenshotGatewayError: Error { case unsupported }
#endif

View File

@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
var username: String var username: String
var receiveFolder: ReceiveFolder var receiveFolder: ReceiveFolder
var themeMode: ThemeMode var themeMode: ThemeMode
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration var relayConfiguration: RelayConfiguration
} }
@@ -129,7 +128,6 @@ struct AppPreferencesDefaults {
let username: String let username: String
let receiveFolder: ReceiveFolder let receiveFolder: ReceiveFolder
let themeMode: ThemeMode let themeMode: ThemeMode
var diagnosticsEnabled: Bool = false
} }
@MainActor @MainActor
@@ -145,7 +143,6 @@ final class AppPreferencesRepository: ObservableObject {
static let receiveFolderValue = "receive_folder_value" static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name" static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode" static let themeMode = "theme_mode"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id" static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration" static let relayConfiguration = "relay_configuration"
} }
@@ -160,13 +157,11 @@ final class AppPreferencesRepository: ObservableObject {
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences( return AppPreferences(
username: username, username: username,
receiveFolder: folder, receiveFolder: folder,
themeMode: themeMode, themeMode: themeMode,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId, diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults) relayConfiguration: resolveRelayConfiguration(defaults)
) )
@@ -219,11 +214,6 @@ final class AppPreferencesRepository: ObservableObject {
reload() reload()
} }
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()
}
func setRelayConfiguration(_ configuration: RelayConfiguration) { func setRelayConfiguration(_ configuration: RelayConfiguration) {
guard let encoded = try? JSONEncoder().encode(configuration) else { return } guard let encoded = try? JSONEncoder().encode(configuration) else { return }
defaults.set(encoded, forKey: Key.relayConfiguration) defaults.set(encoded, forKey: Key.relayConfiguration)

View File

@@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService {
} }
func previewLogBytes() async -> Int { 0 } func previewLogBytes() async -> Int { 0 }
} }
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -44,7 +44,6 @@ struct SettingsState: Equatable {
var supportsCustomReceiveFolders = true var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system var themeMode: ThemeMode = .system
var notificationPermission: NotificationPermission = .notDetermined var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic var relayMode: RelayPreferenceMode = .automatic
var relayURLs: [String] = [] var relayURLs: [String] = []
var relayValidationError: RelayConfigurationValidationError? var relayValidationError: RelayConfigurationValidationError?
@@ -76,7 +75,7 @@ struct SettingsState: Equatable {
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders && lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission && lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs && lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
&& lhs.relayValidationError == rhs.relayValidationError && lhs.relayValidationError == rhs.relayValidationError
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty && lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
@@ -111,7 +110,6 @@ final class SettingsModel: ObservableObject {
private let notifications: LocalNotificationService private let notifications: LocalNotificationService
private let messages: UiMessageController private let messages: UiMessageController
private let bugReports: BugReportService private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var usernamePersistTask: Task<Void, Never>? private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false private var hasLocalUsernameDraft = false
@@ -126,8 +124,7 @@ final class SettingsModel: ObservableObject {
preferences: AppPreferencesRepository, preferences: AppPreferencesRepository,
notifications: LocalNotificationService, notifications: LocalNotificationService,
messages: UiMessageController, messages: UiMessageController,
bugReports: BugReportService, bugReports: BugReportService
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
) { ) {
self.environment = environment self.environment = environment
self.deviceInfoProvider = deviceInfoProvider self.deviceInfoProvider = deviceInfoProvider
@@ -137,7 +134,6 @@ final class SettingsModel: ObservableObject {
self.notifications = notifications self.notifications = notifications
self.messages = messages self.messages = messages
self.bugReports = bugReports self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState( self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders, supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion appVersion: environment.appVersion
@@ -151,7 +147,6 @@ final class SettingsModel: ObservableObject {
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode self.state.themeMode = prefs.themeMode
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft { if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode self.state.relayMode = prefs.relayConfiguration.mode
self.state.relayURLs = prefs.relayConfiguration.relayURLs self.state.relayURLs = prefs.relayConfiguration.relayURLs
@@ -230,17 +225,6 @@ final class SettingsModel: ObservableObject {
} }
} }
func setDiagnosticsEnabled(_ enabled: Bool) {
if !diagnosticsIncluded { return }
Task {
preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage(
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
tone: .success
))
}
}
// MARK: - Network // MARK: - Network
func setRelayMode(_ mode: RelayPreferenceMode) { func setRelayMode(_ mode: RelayPreferenceMode) {

View File

@@ -375,7 +375,7 @@ struct StorageSettings: View {
struct AboutSettings: View { struct AboutSettings: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! private static let privacyPolicyURL = AppConfig.privacyPolicyURL
var body: some View { var body: some View {
Section { Section {
@@ -415,17 +415,6 @@ struct AboutSettings: View {
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised) Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
} }
} }
if DiagnosticsBuildConfig.included {
Section {
Toggle(isOn: Binding(
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(String(localized: L10n.Diagnostics.title))
}
}
}
} }
} }

View File

@@ -1,62 +1,57 @@
{ {
"fill": { "fill" : {
"linear-gradient": [ "linear-gradient" : [
"extended-gray:1.00000,1.00000", "extended-gray:1.00000,1.00000",
"display-p3:0.55433,0.59923,0.92884,1.00000" "srgb:0.84942,0.81480,0.95401,1.00000"
] ]
}, },
"groups": [ "groups" : [
{ {
"blend-mode": "normal", "blend-mode" : "normal",
"blur-material": null, "blur-material" : null,
"layers": [ "layers" : [
{ {
"image-name": "Mask.svg", "image-name" : "Mask.svg",
"name": "Mask" "name" : "Mask"
} }
], ],
"lighting": "individual", "lighting" : "individual",
"refractivity": { "shadow" : {
"depth": 0.5, "kind" : "neutral",
"enabled": true, "opacity" : 0.6
"strength": 0 },
}, "specular" : true,
"shadow": { "translucency" : {
"kind": "neutral", "enabled" : true,
"opacity": 0.6 "value" : 0.8
}, }
"specular": true, },
"translucency": { {
"enabled": true, "layers" : [
"value": 0.8 {
} "image-name" : "Drop.svg",
}, "name" : "Drop"
{ },
"layers": [ {
{ "image-name" : "U.svg",
"image-name": "Drop.svg", "name" : "U"
"name": "Drop" }
}, ],
{ "lighting" : "combined",
"image-name": "U.svg", "shadow" : {
"name": "U" "kind" : "layer-color",
} "opacity" : 0.8
], },
"lighting": "combined", "translucency" : {
"shadow": { "enabled" : true,
"kind": "neutral", "value" : 0.4
"opacity": 0.6 }
}, }
"translucency": { ],
"enabled": true, "supported-platforms" : {
"value": 0.4 "circles" : [
} "watchOS"
} ],
], "squares" : "shared"
"supported-platforms": { }
"circles": [ }
"watchOS"
],
"squares": "shared"
}
}

View File

@@ -20,6 +20,12 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<!-- Required, not optional: VniDrop's iroh (QUIC/UDP) endpoint listens for
and ACCEPTS inbound connections from peers — either device can initiate
a transfer. See HandshakeService::accept in crates/vnidrop/src/handshake.rs.
Removing this breaks the receive/serve half of every transfer under the
sandbox. App Store automated review may flag it (no classic TcpListener);
justify via App Review Information rather than removing. -->
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<true/> <true/>
</dict> </dict>

View File

@@ -159,6 +159,25 @@ targets:
dependencies: dependencies:
- target: VniDrop - target: VniDrop
# UI test bundle used only to capture App Store screenshots (iOS/iPad).
# Driven by apple/scripts/appstore-screenshots.sh.
VniDropUITests:
type: bundle.ui-testing
supportedDestinations: [iOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: UITests
settings:
base:
GENERATE_INFOPLIST_FILE: YES
SWIFT_VERSION: "6.0"
TEST_TARGET_NAME: VniDrop
dependencies:
- target: VniDrop
schemes: schemes:
VniDrop: VniDrop:
build: build:
@@ -188,3 +207,13 @@ schemes:
config: Release-Direct config: Release-Direct
archive: archive:
config: Release-Direct config: Release-Direct
# App Store screenshot capture (see apple/scripts/appstore-screenshots.sh).
VniDropScreenshots:
build:
targets:
VniDrop: all
test:
config: Debug
targets:
- VniDropUITests

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
#
# Captures App Store screenshots for the VniDrop iOS/iPad app by running the
# VniDropUITests screenshot test on a simulator and extracting the attachments
# at the device's native resolution.
#
# Usage:
# apple/scripts/appstore-screenshots.sh [output-dir]
#
# Environment:
# SCREENSHOT_DEVICE Simulator device name (default: "iPad Pro 13-inch (M5)")
# 13-inch iPad → 2064×2752, accepted by App Store Connect.
#
# The output directory receives one PNG per tab (01-Send.png, 02-Receive.png, …).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}"
OUT_DIR="${1:-$HOME/Desktop/vnidrop-appstore-screenshots}"
echo "==> Regenerating Xcode project (picks up the screenshot target)"
(cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Resolving simulator: $DEVICE"
# Match the device name literally (it contains parentheses, e.g. "(M5)"), then
# pull the UUID from the same line.
DEVICE_LINE="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1)"
UDID="$(printf '%s' "$DEVICE_LINE" \
| grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
if [ -z "${UDID:-}" ]; then
echo "error: no available simulator named '$DEVICE'." >&2
echo " list options with: xcrun simctl list devices available" >&2
exit 1
fi
echo " udid: $UDID"
echo "==> Booting simulator"
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
# Clean marketing status bar (Apple's 9:41, full signal/battery).
xcrun simctl status_bar "$UDID" override \
--time "9:41" \
--batteryState charged --batteryLevel 100 \
--cellularMode active --cellularBars 4 \
--wifiMode active --wifiBars 3 >/dev/null 2>&1 || true
RESULT_DIR="$(mktemp -d)"
RESULT="$RESULT_DIR/screenshots.xcresult"
ATT_DIR="$RESULT_DIR/attachments"
trap 'rm -rf "$RESULT_DIR"' EXIT
echo "==> Running UI screenshot test (this builds and launches the app)"
xcodebuild test \
-project "$APPLE_DIR/VniDrop.xcodeproj" \
-scheme VniDropScreenshots \
-destination "platform=iOS Simulator,id=$UDID" \
-resultBundlePath "$RESULT" \
-only-testing:VniDropUITests \
CODE_SIGNING_ALLOWED=NO \
| tail -12
echo "==> Extracting screenshots"
xcrun xcresulttool export attachments --path "$RESULT" --output-path "$ATT_DIR"
mkdir -p "$OUT_DIR"
python3 - "$ATT_DIR" "$OUT_DIR" <<'PY'
import json, os, re, shutil, sys
att_dir, out_dir = sys.argv[1], sys.argv[2]
manifest = os.path.join(att_dir, "manifest.json")
with open(manifest) as f:
data = json.load(f)
# Xcode suffixes attachment names with "_<n>_<uuid>.png"; keep only "NN-Name".
pattern = re.compile(r"^(\d\d-[A-Za-z]+)")
count = 0
def walk(node):
global count
if isinstance(node, dict):
name = node.get("suggestedHumanReadableName")
src = node.get("exportedFileName")
m = pattern.match(name) if name else None
if m and src:
base = f"{m.group(1)}.png"
shutil.copyfile(os.path.join(att_dir, src), os.path.join(out_dir, base))
print(f" {base}")
count += 1
for v in node.values():
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(data)
if count == 0:
sys.exit("error: no named screenshots found in the result bundle")
PY
echo "==> Done. Screenshots in: $OUT_DIR"
ls -1 "$OUT_DIR"

View File

@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
# This never touches the Rust crate — it only changes how the build is invoked. # This never touches the Rust crate — it only changes how the build is invoked.
export CARGO_PROFILE_DEV_STRIP=none export CARGO_PROFILE_DEV_STRIP=none
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
export CARGO_PROFILE_RELEASE_LTO=false
IOS_TARGET="aarch64-apple-ios" IOS_TARGET="aarch64-apple-ios"
SIM_ARM_TARGET="aarch64-apple-ios-sim" SIM_ARM_TARGET="aarch64-apple-ios-sim"
SIM_X64_TARGET="x86_64-apple-ios" SIM_X64_TARGET="x86_64-apple-ios"

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
# so app-wide constants (privacy policy URL, …) have a single source of truth
# across Apple and KMP. Regenerate instead of editing the output.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
read_property() {
local key=$1
local value
value="$(sed -n "s/^${key}=//p" "$config_file")"
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
printf '%s' "$value"
}
# Escape for a Swift string literal.
swift_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
privacy_url="$(read_property PRIVACY_POLICY_URL)"
mkdir -p "$output_dir"
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
cat > "$tmp" <<EOF
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
// Regenerate this file instead of editing it.
import Foundation
/// App-wide constants injected at build time from the shared \`app.properties\`.
enum AppConfig {
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
}
EOF
mv "$tmp" "$output_dir/AppConfig.swift"

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/../generate-appconfig.sh"
repo_root="$(cd "$script_dir/../../.." && pwd)"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
# Run the generator against a fixture app.properties, emitting into a temp dir.
generate() {
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
"$generator"
}
expect_failure() {
if "$@" >/dev/null 2>&1; then
printf 'Expected command to fail: %s\n' "$*" >&2
exit 1
fi
}
assert_contains() {
local file=$1 needle=$2
grep -qF "$needle" "$file" ||
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
}
out="$scratch/out/AppConfig.swift"
# 1. Nominal value is emitted verbatim as a Swift URL literal.
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
generate
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
assert_contains "$out" 'enum AppConfig'
# 2. Characters special to a Swift string literal are escaped.
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
generate
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
# 3. A missing key fails instead of emitting an empty value.
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
expect_failure generate
# 4. A duplicated key fails.
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
expect_failure generate
# 5. The real committed app.properties produces an https URL.
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
printf 'generate-appconfig tests passed.\n'

40
ci_scripts/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Xcode Cloud CI scripts
Xcode Cloud runs the scripts in this directory around each build. Only
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
`ci_post_xcodebuild.sh` here if later steps are needed.
## What `ci_post_clone.sh` does
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
committed — they are produced by XcodeGen, localization, and the Rust core build.
Since Xcode Cloud only checks out the repository, the post-clone script:
1. installs `swiftlint`, `xcodegen`, and `bun`;
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
never builds Rust;
3. runs localization + version/app config codegen and `xcodegen generate`
(equivalent to `make apple-project` without the `apple-core` step).
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
before an Xcode Cloud build for that version runs (see
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
### Overrides (env vars, optional)
| Variable | Default | Purpose |
|----------|---------|---------|
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
## Workflow configuration (App Store Connect)
The workflow itself (product, scheme, triggers, actions) is configured in App
Store Connect, not in the repository. Point it at:
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
Archive actions use the release Rust profile via the published core asset; build
and test actions reuse the same prebuilt core.

72
ci_scripts/ci_post_clone.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
# Xcode Cloud post-clone step.
#
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
# only checks out the repository, so this script:
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
# matching GitHub Release asset — we never build Rust here;
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
#
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
# points at the checked-out repository root.
set -euo pipefail
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
cd "$REPO_ROOT"
echo "==> Installing build tooling (Homebrew)"
# swiftlint: enforced by a build phase (fails the build if missing).
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
brew install swiftlint xcodegen
echo "==> Installing Bun (localization generator)"
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash
fi
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
export PATH="$BUN_INSTALL/bin:$PATH"
# --- Prebuilt core: download instead of building Rust -------------------------
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
# by apple/scripts/package-core.sh. See docs at the top of that script.
VERSION="$(packaging/version/resolve-version.sh product)"
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
CORE_ZIP="VnidropCore-$VERSION.zip"
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
DOWNLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
echo "==> Verifying checksum"
(
cd "$DOWNLOAD_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum --check "$CORE_ZIP.sha256"
else
shasum -a 256 --check "$CORE_ZIP.sha256"
fi
)
echo "==> Installing core into apple/VnidropCore"
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
rm -rf "$PKG_DIR/vnidrop.xcframework"
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
mkdir -p "$PKG_DIR/Sources/VnidropCore"
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
# --- Generate the project (everything except the Rust core) -------------------
echo "==> Generating localization, version/app config, and the Xcode project"
make localization apple-version-config apple-app-config
(cd "$REPO_ROOT/apple" && xcodegen generate)
echo "==> ci_post_clone complete"

View File

@@ -52,6 +52,13 @@ impl HandshakeService {
} }
impl ProtocolHandler for HandshakeService { impl ProtocolHandler for HandshakeService {
// This handler ACCEPTS inbound QUIC connections from peers: iroh binds a
// listening endpoint and this device responds to connections initiated by
// the remote side. On macOS this is why the App Sandbox requires the
// `com.apple.security.network.server` entitlement (see
// apple/VniDrop/Resources/VniDrop.entitlements) in addition to
// `network.client` — either peer can initiate a transfer, so both the
// client and server sides of a connection are used.
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote_endpoint_id = connection.remote_id().to_string(); let remote_endpoint_id = connection.remote_id().to_string();

View File

@@ -3,14 +3,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy policy", title: "Privacy policy",
description: description:
"How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.", "How VniDrop handles transfers, local app data, optional bug reports, and website visits.",
}; };
const sections = [ const sections = [
["scope", "Scope"], ["scope", "Scope"],
["transfers", "Transfers"], ["transfers", "Transfers"],
["local-data", "Local data"], ["local-data", "Local data"],
["diagnostics", "Diagnostics"], ["bug-reports", "Bug reports"],
["website", "Website"], ["website", "Website"],
["permissions", "Permissions"], ["permissions", "Permissions"],
["providers", "Service providers"], ["providers", "Service providers"],
@@ -29,9 +29,9 @@ export default function PrivacyPage() {
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
<p> <p>
This policy explains what moves between devices, what stays local, and what is sent This policy explains what moves between devices, what stays local, and what is sent
only when you choose to share diagnostics or a bug report. only when you choose to submit a bug report.
</p> </p>
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p> <p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
</div> </div>
</section> </section>
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
<p> <p>
VniDrop has no user accounts and does not upload your transfer to a VniDrop file VniDrop has no user accounts and does not upload your transfer to a VniDrop file
store. Files travel over an authenticated, end-to-end encrypted connection. store. Files travel over an authenticated, end-to-end encrypted connection.
Product diagnostics are opt-in; a bug report is sent only when you submit one. VniDrop has no telemetry or analytics; a bug report is sent only when you submit one.
</p> </p>
</div> </div>
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
<h2>Scope and who VniDrop means</h2> <h2>Scope and who VniDrop means</h2>
<p> <p>
This policy covers the official VniDrop website, the VniDrop applications for This policy covers the official VniDrop website, the VniDrop applications for
Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by Android, iOS, macOS, Windows, and Linux, and the bug-report service configured by
the official project. For an official release, VniDrops data controller is the the official project. For an official release, VniDrops data controller is the
individual publisher named in the applicable app-store listing. In this policy, individual publisher named in the applicable app-store listing. In this policy,
VniDrop, we, and us also include the maintainers acting on that publishers VniDrop, we, and us also include the maintainers acting on that publishers
@@ -72,7 +72,7 @@ export default function PrivacyPage() {
</p> </p>
<p> <p>
VniDrop is open-source software. A build distributed or operated by someone else VniDrop is open-source software. A build distributed or operated by someone else
may use different networking infrastructure, diagnostics settings, or website may use different networking infrastructure, bug-report settings, or website
hosting. That distributor is responsible for explaining its own practices. hosting. That distributor is responsible for explaining its own practices.
</p> </p>
</section> </section>
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
<ul> <ul>
<li>device identity and networking keys used to establish secure connections;</li> <li>device identity and networking keys used to establish secure connections;</li>
<li>active shares, transfer history, receiver requests, progress, and status;</li> <li>active shares, transfer history, receiver requests, progress, and status;</li>
<li>app preferences, including access and diagnostics choices;</li> <li>app preferences, including access choices;</li>
<li>download destinations and locally managed transfer data; and</li> <li>download destinations and locally managed transfer data; and</li>
<li>an anonymous installation identifier used only for diagnostics correlation.</li> <li>an anonymous installation identifier used only for bug-report correlation.</li>
</ul> </ul>
<p> <p>
This information remains until you remove the relevant history, stop or delete a This information remains until you remove the relevant history, stop or delete a
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section id="diagnostics" className="policy-section"> <section id="bug-reports" className="policy-section">
<h2>Optional diagnostics and bug reports</h2> <h2>Optional bug reports</h2>
<h3>Automatic product diagnostics</h3>
<p> <p>
Official releases indicate in the app settings whether automatic product VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting.
diagnostics are included. When included, automatic usage events and crash reports Nothing is sent to a bug-report service unless you explicitly submit a report.
are disabled until you enable Share diagnostics. If enabled, VniDrop may send an
anonymous installation ID, app version, platform, sparse event names and properties,
crash type and message, a redacted stack trace, timestamps, and recent in-app
breadcrumbs. You can turn this off at any time; doing so also removes pending local
crash reports.
</p> </p>
<h3>User-submitted bug reports</h3> <h3>User-submitted bug reports</h3>
<p> <p>
A bug report is separate from the diagnostics toggle and is sent only when you press A bug report is sent only when you press submit. It can contain what you say
submit. It can contain what you say happened, what you expected, reproduction steps, happened, what you expected, reproduction steps, an optional contact email, app and
an optional contact email, app and platform versions, an anonymous installation ID, platform versions, an anonymous installation ID, device name and model, operating
device name and model, operating system, network and battery information, recent system, network and battery information, and optional recent logs. You can exclude
breadcrumbs, and optional recent logs. You can exclude logs before submitting. logs before submitting.
</p> </p>
<h3>Data deliberately excluded</h3> <h3>Data deliberately excluded</h3>
<p> <p>
Automatic diagnostics are designed to exclude transfer contents, invitations, and Bug reports are designed to exclude transfer contents, invitations, and file paths.
file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules Before optional logs are sent, VniDrop applies rules intended to redact invitation
intended to redact invitation tokens, endpoint identifiers, absolute paths, file and tokens, endpoint identifiers, absolute paths, file and content URIs, and platform
content URIs, and platform document identifiers. No redaction system is perfect, so document identifiers. No redaction system is perfect, so review anything you type
review anything you type into a bug report and avoid including secrets. into a bug report and avoid including secrets.
</p> </p>
</section> </section>
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
<dt>Cloudflare</dt> <dt>Cloudflare</dt>
<dd> <dd>
Proxies website requests and provides DNS, security, and abuse controls. When Proxies website requests and provides DNS, security, and abuse controls. When
the optional diagnostics service is configured, it uses Cloudflare Workers, D1, the optional bug-report service is configured, it uses Cloudflare Workers, D1,
and R2. and R2.
</dd> </dd>
</div> </div>
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
<td>Until you delete them, clear app data, or uninstall</td> <td>Until you delete them, clear app data, or uninstall</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Pending local crash reports</th> <th scope="row">Server bug reports</th>
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
</tr>
<tr>
<th scope="row">Server diagnostics and bug reports</th>
<td>The current project configuration is 90 days, with scheduled deletion</td> <td>The current project configuration is 90 days, with scheduled deletion</td>
</tr> </tr>
<tr> <tr>
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
<p> <p>
Operational backups, provider logs, and deletion backlogs may persist briefly beyond Operational backups, provider logs, and deletion backlogs may persist briefly beyond
the stated period where necessary for security, integrity, or legal obligations. If the stated period where necessary for security, integrity, or legal obligations. If
the production diagnostics retention configuration changes, this policy should be the production bug-report retention configuration changes, this policy should be
updated to match it. updated to match it.
</p> </p>
</section> </section>
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
<section id="choices" className="policy-section"> <section id="choices" className="policy-section">
<h2>Your choices and rights</h2> <h2>Your choices and rights</h2>
<ul> <ul>
<li>Enable or disable Share diagnostics in VniDrop settings.</li>
<li> <li>
Submit a bug report only when you choose, omit contact information, and exclude Submit a bug report only when you choose, omit contact information, and exclude
logs. logs.
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
<p> <p>
Depending on where you live, privacy law may provide rights to access, correct, Depending on where you live, privacy law may provide rights to access, correct,
delete, restrict, or object to processing of personal information. Because VniDrop delete, restrict, or object to processing of personal information. Because VniDrop
has no account and automatic diagnostics use an anonymous installation ID, we may has no account and bug reports use an anonymous installation ID, we may
not be able to connect a server record to you without additional information. Use not be able to connect a server record to you without additional information. Use
the contact method below and provide only what is needed to locate your submission. the contact method below and provide only what is needed to locate your submission.
</p> </p>
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
<h2>Security</h2> <h2>Security</h2>
<p> <p>
VniDrop uses authenticated end-to-end encrypted connections, content verification, VniDrop uses authenticated end-to-end encrypted connections, content verification,
deny-by-default share access, bounded diagnostics payloads, redaction, and safe file deny-by-default share access, bounded bug-report payloads, redaction, and safe file
publishing that avoids silently replacing an existing file. No system can guarantee publishing that avoids silently replacing an existing file. No system can guarantee
absolute security. Keep invitations private, verify receiver names, keep your device absolute security. Keep invitations private, verify receiver names, keep your device
updated, and stop sharing when a transfer is finished. updated, and stop sharing when a transfer is finished.

View File

@@ -13,9 +13,8 @@ android.nonTransitiveRClass=true
android.sourceset.disallowProvider=false android.sourceset.disallowProvider=false
android.useAndroidX=true android.useAndroidX=true
# VniDrop: compile-time diagnostics/telemetry product surface. # VniDrop: compile-time bug-report delivery surface.
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack. # false → user-initiated bug reports fall back to a NoOp transport (never sent).
# Bug report UI remains available (user-initiated).
# Enable per build only when endpoint and ingest key are configured: # Enable per build only when endpoint and ingest key are configured:
# ./gradlew … -Pvnidrop.diagnostics.included=true # ./gradlew … -Pvnidrop.diagnostics.included=true
vnidrop.diagnostics.included=false vnidrop.diagnostics.included=false

View File

@@ -1178,62 +1178,6 @@
"ru": "Имя устройства" "ru": "Имя устройства"
} }
}, },
"diagnostics_description": {
"context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.",
"translations": {
"en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.",
"fr": "Envoyer des rapports de plantage et des événements dutilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.",
"es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.",
"it": "Invia report di arresto anomalo ed eventi duso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.",
"de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.",
"pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.",
"pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.",
"nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.",
"ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются."
}
},
"diagnostics_disabled_message": {
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.",
"translations": {
"en": "Diagnostics sharing is off.",
"fr": "Le partage des diagnostics est désactivé.",
"es": "El uso compartido de diagnósticos está desactivado.",
"it": "La condivisione dei dati diagnostici è disattivata.",
"de": "Die Freigabe von Diagnosedaten ist deaktiviert.",
"pt": "A partilha de diagnósticos está desativada.",
"pl": "Udostępnianie diagnostyki jest wyłączone.",
"nl": "Het delen van diagnostische gegevens is uitgeschakeld.",
"ru": "Передача диагностики отключена."
}
},
"diagnostics_enabled_message": {
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.",
"translations": {
"en": "Diagnostics sharing is on.",
"fr": "Le partage des diagnostics est activé.",
"es": "El uso compartido de diagnósticos está activado.",
"it": "La condivisione dei dati diagnostici è attivata.",
"de": "Die Freigabe von Diagnosedaten ist aktiviert.",
"pt": "A partilha de diagnósticos está ativada.",
"pl": "Udostępnianie diagnostyki jest włączone.",
"nl": "Het delen van diagnostische gegevens is ingeschakeld.",
"ru": "Передача диагностики включена."
}
},
"diagnostics_title": {
"context": "Settings > Diagnostics: toggle title.",
"translations": {
"en": "Share diagnostics",
"fr": "Partager les diagnostics",
"es": "Compartir diagnósticos",
"it": "Condividi dati diagnostici",
"de": "Diagnosedaten teilen",
"pt": "Partilhar diagnósticos",
"pl": "Udostępniaj diagnostykę",
"nl": "Diagnostische gegevens delen",
"ru": "Делиться диагностикой"
}
},
"error_camera": { "error_camera": {
"context": "Error: camera permission is needed to scan a QR code.", "context": "Error: camera permission is needed to scan a QR code.",
"translations": { "translations": {
@@ -1796,6 +1740,57 @@
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне." "ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
} }
}, },
"notifications_background_sharing_body": {
"context": "Android foreground-service notification: explains why VniDrop stays active.",
"targets": [
"kmp"
],
"translations": {
"en": "VniDrop is ready to share your files in the background.",
"fr": "VniDrop est prêt à partager vos fichiers en arrière-plan.",
"es": "VniDrop está listo para compartir sus archivos en segundo plano.",
"it": "VniDrop è pronto a condividere i tuoi file in background.",
"de": "VniDrop kann Ihre Dateien im Hintergrund freigeben.",
"pt": "O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.",
"pl": "VniDrop jest gotowy do udostępniania plików w tle.",
"nl": "VniDrop is klaar om uw bestanden op de achtergrond te delen.",
"ru": "VniDrop готов отправлять ваши файлы в фоновом режиме."
}
},
"notifications_background_sharing_channel": {
"context": "Android system notification channel for an active outgoing share.",
"targets": [
"kmp"
],
"translations": {
"en": "Active transfers",
"fr": "Transferts actifs",
"es": "Transferencias activas",
"it": "Trasferimenti attivi",
"de": "Aktive Übertragungen",
"pt": "Transferências ativas",
"pl": "Aktywne transfery",
"nl": "Actieve overdrachten",
"ru": "Активные передачи"
}
},
"notifications_background_sharing_title": {
"context": "Android foreground-service notification title while an outgoing share is available.",
"targets": [
"kmp"
],
"translations": {
"en": "Sharing in the background",
"fr": "Partage en arrière-plan",
"es": "Compartiendo en segundo plano",
"it": "Condivisione in background",
"de": "Freigabe im Hintergrund",
"pt": "Partilha em segundo plano",
"pl": "Udostępnianie w tle",
"nl": "Delen op de achtergrond",
"ru": "Отправка в фоне"
}
},
"notifications_enabled_message": { "notifications_enabled_message": {
"context": "Settings > Notifications: confirmation when notifications are enabled.", "context": "Settings > Notifications: confirmation when notifications are enabled.",
"translations": { "translations": {

4
packaging/apple/studio/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
generated/
out-*.png
.build/
.swiftpm/

View File

@@ -0,0 +1,13 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "studio",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(
name: "studio",
path: "Sources/studio"
)
]
)

View File

@@ -0,0 +1,76 @@
# App Store screenshot studio
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
the device screens are captured from the real app.
## Requirements
- Xcode / Swift toolchain (macOS 26+). That's it.
## Run
```sh
swift run studio # all locales × screens -> generated/<Language>/
swift run studio --publish # -> ../<Language>/ (ships to App Store)
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
PLATFORM=ipad swift run studio # iPad set -> generated/<Language>/iPad/
PLATFORM=mac swift run studio # Mac set -> generated/<Language>/Mac/
```
`PLATFORM` must match between `capture.sh` and `swift run studio` (they read the same
`generated/shots/<platform>/` tree).
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
## Full pipeline
```sh
./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
swift run studio # composite everything -> generated/<Language>/[iPad/]
./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
```
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
`-AppleLanguages`, in dark mode. Screenshots are transient (git-ignored, regenerated per
run) — during layout iteration, capture once then re-run `swift run studio` on its own.
- **iPhone / iPad** run in the simulator (9:41 status-bar override) via `simctl`.
- **Mac** has no simulator: `capture.sh` builds the native app, launches the binary
directly, sizes its window with `osascript`, and grabs it with `screencapture`. Grant
the terminal **Accessibility + Screen Recording** permission the first time.
## Platforms
`PLATFORM=iphone` (default), `ipad`, or `mac`. Each `Platform`
(`Sources/studio/Platform.swift`) carries its canvas size, capture target, output
subfolder, and a `DeviceModel` — the `.usdz`, its screen material, the orientation fix
(yaw so the screen faces the camera), optional front-glass mesh to hide, whether to tint
the body graphite, a `fillFraction` (how much of the frame the device fills; lower for
wide 3/4 poses that would otherwise clip), and a `screenPad` (black margin baked around
the shot so the window clears the display's rounded corners). Adding a device is a new
`DeviceModel` + `Platform` case + a layout set. (The studio is Apple-only today; the
same SwiftUI/SceneKit approach is intended to extend to Android and Windows later.)
## Device rendering
`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
auto-generates planar `[0,1]` UVs for screen meshes that ship without them, and mattes the
shot onto a slightly larger black canvas (`screenPad`) so the display edge stays black
instead of smearing the capture's corner pixels.
## Assets (`assets/`)
- `iphone-17-pro-max.usdz`, `ipad-pro.usdz`, `macbook-air.usdz` — the 3D devices
(committed; CC BY 4.0, see `ATTRIBUTION.md`).
- `globe.png` — for the send-anywhere hero (committed).
- `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
## Layout & tuning
- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad` / `mac`):
gradient, device pose/position/size, globe + route (send-anywhere), the encryption flow
(stay-private: `beams``lock``stream` + `banners`), caption placement. `beams` and
`stream` take a `horizontal` flag for side-by-side devices (used by Mac stay-private).
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
generated artwork).
## Screens
`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
reuses the share screenshot), `stay-private` (encryption beams → padlock → binary
protection stream, with localized CHIFFREMENT/PROTECTION banners; two stacked phones on
iPhone/iPad, two side-by-side laptops with a horizontal flow on Mac).

View File

@@ -0,0 +1,10 @@
import SwiftUI
import AppKit
// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
// current implementation (textures the app screenshot onto a real .usdz model).
protocol DeviceRenderer {
// Produces the device as a SwiftUI view sized to `spec.height` (width follows the
// device aspect), already framing `shot` and posed per `spec`.
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView
}

View File

@@ -0,0 +1,77 @@
import Foundation
// A 3D device model + the per-model quirks the renderer needs (screen material name,
// orientation fix so the screen faces the camera, optional front-glass mesh to hide,
// whether to tint the body graphite).
struct DeviceModel {
var url: URL
var screenMaterial: String
var glassMaterial: String? // nil = no separate glass mesh
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
var recolorBody: Bool = false // graphite tint
// Fraction of the square render the device's upright height fills. Lower = more margin
// for wide 3/4 poses (a laptop's fanned base projects past a tight frame and clips).
var fillFraction: CGFloat = 0.66
// Overscan for auto-generated screen UVs: >0 shrinks the screenshot slightly so its
// edges aren't hidden when the screen mesh is a touch larger than the visible display.
var screenPad: CGFloat = 0
static func iphone(_ assets: URL) -> DeviceModel {
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
screenMaterial: "_7ProMax_Screen", glassMaterial: "glass",
bodyYaw: 0, recolorBody: true)
}
static func ipad(_ assets: URL) -> DeviceModel {
// Screen mesh "Material_002" (display only; the bezel is real geometry). Front
// faces +Z, so yaw 180° to face -Z. Standard UVs no remap or bezel padding.
DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"),
screenMaterial: "Material_002", glassMaterial: nil,
bodyYaw: 180, recolorBody: true)
}
static func macbook(_ assets: URL) -> DeviceModel {
// Open-laptop model; display mesh "screen_black" (no UVs auto-generated). Front
// faces +Z, so yaw 180° to face -Z.
DeviceModel(url: assets.appendingPathComponent("macbook-air.usdz"),
screenMaterial: "screen_black", glassMaterial: nil,
bodyYaw: 180, recolorBody: false, fillFraction: 0.46, screenPad: 0.008)
}
}
// An App Store target: canvas size, device model, capture simulator, output subfolder.
enum Platform: String {
case iphone, ipad, mac
var canvas: CGSize {
switch self {
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
}
}
func deviceModel(assets: URL) -> DeviceModel {
switch self {
case .iphone: DeviceModel.iphone(assets)
case .ipad: DeviceModel.ipad(assets)
case .mac: DeviceModel.macbook(assets)
}
}
// Simulator used by capture.sh (informational here; capture reads its own env).
var simulator: String {
switch self {
case .iphone: "iPhone 17 Pro Max"
case .ipad: "iPad Pro 13-inch (M5)"
case .mac: "My Mac"
}
}
// Output goes under <Language>/<subfolder> so the sets stay separate.
var outputSubfolder: String {
switch self {
case .iphone: "iPhone"
case .ipad: "iPad"
case .mac: "Mac"
}
}
}

View File

@@ -0,0 +1,266 @@
import SwiftUI
import SceneKit
import AppKit
// Full 3D device layer: textures the app screenshot onto the real iPhone .usdz screen
// mesh and renders it with SceneKit off-screen. Slots behind the same DeviceRenderer
// protocol as the 2D compositor ScreenFrame is unchanged. Tilt is baked into the
// scene (a real camera + perspective), not faked.
struct SceneKitDeviceRenderer: DeviceRenderer {
let model: DeviceModel
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
@MainActor
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
// Render into a square (so any pose fits without clipping); the upright phone
// occupies `heightFraction` of it, so displaySide maps that to spec.height.
let displaySide = spec.height / model.fillFraction
let renderSide = min(displaySide * supersample, 3200)
guard let img = render(shot: shot, spec: spec, side: renderSide) else {
return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
}
return AnyView(Image(nsImage: img).resizable().frame(width: displaySide, height: displaySide))
}
@MainActor
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
guard let scene = try? SCNScene(url: model.url),
let device = MTLCreateSystemDefaultDevice() else { return nil }
let root = scene.rootNode
let d2r = Double.pi / 180
// Orient the model so its screen faces the camera (-Z). Some models (iPad) have
// the screen on ±X, so a per-model yaw fix is applied first.
let oriented = SCNNode()
for child in root.childNodes { oriented.addChildNode(child) }
oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0)
// Re-parent under a pivot so the pose rotates it about its own centre. Compute the
// (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates
// the big ancestor unit-scale correctly (flattenedClone collapses these models).
let pivot = SCNNode()
pivot.addChildNode(oriented)
root.addChildNode(pivot)
let (bmin, bmax) = pivot.boundingBox
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
let height = CGFloat(bmax.y - bmin.y)
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
pivot.position = center
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
// The model's screen carries a wavy normal map (a "screen protector" look) that
// ripples the image clear it so the app content stays crisp, as Apple requires.
// Some screen meshes ship with no UV coordinates (an image can't map onto them
// it renders as a flat colour). Generate planar UVs from the vertex positions.
addPlanarUVsToScreen(in: pivot, material: model.screenMaterial)
// Texture EVERY material with the screen name (some models split the display into
// several meshes that share the material name); setting only the first leaves the
// rest white.
// A thin black margin baked around the shot so the window's own corners clear the
// display's rounded edge done in the image (not via UV overscan) so the clamped
// border samples black instead of smearing the capture's rounded-corner pixels.
let screenShot = (shot != nil && model.screenPad > 0) ? matted(shot!, pad: model.screenPad) : shot
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
mat.lightingModel = .constant
mat.normal.contents = nil
mat.emission.contents = nil
mat.metalness.contents = NSNumber(value: 0)
mat.roughness.contents = NSNumber(value: 1)
mat.isDoubleSided = false
mat.diffuse.wrapS = .clamp
mat.diffuse.wrapT = .clamp
}
// Hide the front glass mesh (if any) it has its own normal-mapped waviness that
// reflects as swirls over the screen. We keep the crisp display instead.
if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
// Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
if model.recolorBody { recolorBody(in: pivot) }
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders
// nothing here, but a straight 180° yaw does. Framed so an upright phone height
// fills `heightFraction` of the square canvas (leaving margin for the pose).
let fovV = 18.0
let cam = SCNCamera()
cam.usesOrthographicProjection = false
cam.fieldOfView = fovV
cam.projectionDirection = .vertical
// zFar must comfortably exceed the camera distance; models differ hugely in unit
// scale (iPhone ~16 units tall, iPad ~300), so keep this large.
cam.zNear = 0.01; cam.zFar = 100_000
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
// real product shot instead of clipping flat.
cam.wantsHDR = true
cam.wantsExposureAdaptation = false
cam.bloomThreshold = 0.9
cam.bloomIntensity = 0.25
cam.bloomBlurRadius = 6
let camNode = SCNNode(); camNode.camera = cam
let dist = Double(height) / (2 * Double(model.fillFraction) * tan(fovV / 2 * .pi / 180))
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
root.addChildNode(camNode)
lightRig(into: root, center: center)
// Image-based lighting: a studio softbox environment gives the metal frame and
// glass real reflections/highlights this is what stops it looking plasticky.
scene.lightingEnvironment.contents = Self.studioEnvironment
scene.lightingEnvironment.intensity = 1.5
let renderer = SCNRenderer(device: device, options: nil)
renderer.scene = scene
renderer.pointOfView = camNode
renderer.autoenablesDefaultLighting = false
let px = CGSize(width: side, height: side)
// First snapshot primes the renderer and comes back empty; the second is real.
_ = renderer.snapshot(atTime: 0, with: CGSize(width: 16, height: 16), antialiasingMode: .none)
return renderer.snapshot(atTime: 0, with: px, antialiasingMode: .multisampling4X)
}
@MainActor
private func lightRig(into root: SCNNode, center: SCNVector3) {
func light(_ type: SCNLight.LightType, _ intensity: CGFloat, at pos: SCNVector3) {
let l = SCNLight(); l.type = type; l.intensity = intensity; l.temperature = 6500
l.castsShadow = false
let n = SCNNode(); n.light = l; n.position = pos; n.look(at: center)
root.addChildNode(n)
}
// Low ambient (the environment map does the fill); a crisp key for the highlight
// streak down the frame, a fill from the other side, and a top rim so the upper
// frame/bezel catches a highlight too instead of reading flat.
light(.ambient, 180, at: center)
light(.directional, 850, at: SCNVector3(center.x - 0.5, center.y + 0.6, center.z - 0.8))
light(.directional, 320, at: SCNVector3(center.x + 0.7, center.y - 0.2, center.z - 0.6))
light(.directional, 1100, at: SCNVector3(center.x - 0.15, center.y + 1.3, center.z - 0.5)) // top rim
light(.spot, 900, at: SCNVector3(center.x + 0.2, center.y + 1.1, center.z - 0.7)) // top glint
}
// A procedural equirectangular studio environment: bright "ceiling" softbox fading to
// a darker floor, so reflective surfaces show a gradient with a hot highlight band.
static let studioEnvironment: NSImage = {
let w = 1024, h = 512
let img = NSImage(size: NSSize(width: w, height: h))
img.lockFocus()
let grad = NSGradient(colorsAndLocations:
(NSColor(white: 1.0, alpha: 1), 0.0), // top = ceiling (bright)
(NSColor(white: 0.9, alpha: 1), 0.28),
(NSColor(white: 0.5, alpha: 1), 0.55),
(NSColor(white: 0.22, alpha: 1), 0.8),
(NSColor(white: 0.1, alpha: 1), 1.0)) // bottom = floor (dark)
grad?.draw(in: NSRect(x: 0, y: 0, width: w, height: h), angle: 90)
// Two softbox bands (upper "ceiling" + lower "floor bounce") so reflective
// surfaces catch a highlight at both the top and bottom of the frame.
NSColor.white.setFill()
NSBezierPath(roundedRect: NSRect(x: w / 4, y: Int(Double(h) * 0.66), width: w / 2, height: h / 8),
xRadius: 20, yRadius: 20).fill()
NSColor(white: 0.75, alpha: 1).setFill()
NSBezierPath(roundedRect: NSRect(x: w / 5, y: Int(Double(h) * 0.24), width: Int(Double(w) * 0.6), height: h / 10),
xRadius: 16, yRadius: 16).fill()
img.unlockFocus()
return img
}()
// Tint the phone body graphite. Skips the screen, hidden glass, and camera lenses.
// Uses `multiply` so the metallic reflections survive (just darkened), giving a
// space-black look with sharp rail highlights rather than bright silver.
private func recolorBody(in node: SCNNode) {
let skip = ["screen", "glass", "lens", "logo"]
let graphite = NSColor(calibratedRed: 0.19, green: 0.19, blue: 0.21, alpha: 1)
func walk(_ n: SCNNode) {
for m in n.geometry?.materials ?? [] {
let name = (m.name ?? "").lowercased()
// Never tint the screen its material name may not contain "screen"
// (the iPad's is just "Material").
if m.name == model.screenMaterial { continue }
if skip.contains(where: { name.contains($0) }) { continue }
m.multiply.contents = graphite
// Sharper, brighter specular so the frame highlights "pop" like polished
// metal instead of a soft satin.
m.metalness.contents = NSNumber(value: 1.0)
m.roughness.contents = NSNumber(value: 0.22)
}
n.childNodes.forEach(walk)
}
walk(node)
}
private func hideMeshes(withMaterial substring: String, in node: SCNNode) {
if node.geometry?.materials.contains(where: { ($0.name ?? "").localizedCaseInsensitiveContains(substring) }) == true {
node.isHidden = true
}
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
}
// Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
// has none, so a screenshot texture maps across the display.
private func addPlanarUVsToScreen(in node: SCNNode, material name: String) {
guard let g = node.geometry,
g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }),
g.sources(for: .texcoord).isEmpty,
let vsrc = g.sources(for: .vertex).first else {
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
return
}
// Read vertex positions.
var pos: [(Float, Float, Float)] = []
let stride = vsrc.dataStride, off = vsrc.dataOffset
vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in
for i in 0..<vsrc.vectorCount {
let b = off + i * stride
pos.append((p.load(fromByteOffset: b, as: Float.self),
p.load(fromByteOffset: b + 4, as: Float.self),
p.load(fromByteOffset: b + 8, as: Float.self)))
}
}
let xs = pos.map(\.0), ys = pos.map(\.1), zs = pos.map(\.2)
let ext = [xs.max()! - xs.min()!, ys.max()! - ys.min()!, zs.max()! - zs.min()!]
// Screen plane = the two axes with the largest extent (drop the thin normal axis).
let normalAxis = ext.firstIndex(of: ext.min()!)!
let planeAxes = [0, 1, 2].filter { $0 != normalAxis } // [u-axis, v-axis]
func comp(_ p: (Float, Float, Float), _ a: Int) -> Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) }
let ua = planeAxes[0], va = planeAxes[1]
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua])
let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
// Plain [0,1] planar mapping the screenshot fills the mesh exactly. Any margin the
// display needs is baked into the image (see `matted`), not added here, so the clamped
// edge stays black instead of smearing the capture's corner pixels.
let uvs: [CGPoint] = pos.map {
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
}
let uvSource = SCNGeometrySource(textureCoordinates: uvs)
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
elements: g.elements)
newGeo.materials = g.materials
node.geometry = newGeo
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
}
// Return the shot centred on a black canvas `pad` larger on each side, so texturing it
// leaves a thin black border around the window inside the display's rounded corners.
private func matted(_ shot: NSImage, pad: CGFloat) -> NSImage {
let s = shot.size
let canvas = NSSize(width: s.width * (1 + 2 * pad), height: s.height * (1 + 2 * pad))
let out = NSImage(size: canvas)
out.lockFocus()
NSColor.black.setFill()
NSRect(origin: .zero, size: canvas).fill()
shot.draw(in: NSRect(x: s.width * pad, y: s.height * pad, width: s.width, height: s.height))
out.unlockFocus()
return out
}
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
var out = (node.geometry?.materials ?? []).filter {
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
}
for c in node.childNodes { out += materials(named: name, in: c) }
return out
}
}

View File

@@ -0,0 +1,230 @@
import SwiftUI
// The full 1284x2778 marketing frame: gradient + globe + device + caption.
// Placement mirrors the old screens.typ: images are pinned top-left and pushed by
// (dx, dy); captions are centered and pinned to the top or bottom edge.
struct ScreenFrame: View {
let spec: ScreenSpec
let caption: Caption
let globe: Image?
let shot: NSImage?
let device: DeviceRenderer?
var canvas: CGSize = CGSize(width: 1284, height: 2778)
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
private var titleColor: Color { spec.captionTheme == .light ? .white : Color(hex: "#1b1226") }
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
var body: some View {
let cw = canvas.width, ch = canvas.height
ZStack(alignment: .topLeading) {
spec.bg.gradient
if let g = spec.globe, let globe {
globe.resizable().scaledToFit()
.frame(width: g.width)
.saturation(0.82)
.brightness(-0.06)
.offset(x: g.dx, y: g.dy)
}
// Route sits on the globe but behind the phone, so it appears to pass through.
if let rt = spec.route { routeLayer(rt) }
// Encryption flow (behind the phones so beams/stream tuck into them).
if let b = spec.beams { beamsLayer(b) }
if let s = spec.stream { streamLayer(s) }
if let device {
// The renderer bakes the pose into the 3D scene; we place each phone by
// its center on the canvas and add a soft contact shadow.
ForEach(Array(allDevices.enumerated()), id: \.offset) { _, d in
device.view(shot: shot, spec: d)
.shadow(color: d.shadow ? .black.opacity(0.28) : .clear,
radius: 60, x: 0, y: 34)
.position(x: d.cx, y: d.cy)
}
}
// Padlock + banner labels sit on top of the flow.
if let l = spec.lock { lockLayer(l) }
ForEach(Array(spec.banners.enumerated()), id: \.offset) { _, b in
bannerLayer(b)
}
captionLayer
}
// Pin to top-leading: oversized layers (e.g. the globe frame is wider than the
// canvas) must be clipped from the origin, NOT re-centered otherwise the whole
// composition, captions included, shifts left by (contentWidth - cw) / 2.
.frame(width: cw, height: ch, alignment: .topLeading)
.clipped()
}
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
let u = 1 - t
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
return CGPoint(x: a * p0.x + b * c1.x + c * c2.x + d * p1.x,
y: a * p0.y + b * c1.y + c * c2.y + d * p1.y)
}
static func bezierTangent(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
let u = 1 - t
let a = 3 * u * u, b = 6 * u * t, c = 3 * t * t
return CGPoint(x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p1.x - c2.x),
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p1.y - c2.y))
}
// Transfer route: a glowing arc from the departure city to the arrival city, with a
// pulsing marker at each end. Drawn on the globe, behind the phone.
private func routeLayer(_ r: RouteSpec) -> some View {
let color = Color(hex: r.color)
let path = Path { p in
p.move(to: r.from)
p.addCurve(to: r.to, control1: r.c1, control2: r.c2)
}
return ZStack {
path.stroke(color.opacity(0.55), style: StrokeStyle(lineWidth: r.lineWidth * 2.6, lineCap: .round))
.blur(radius: 22)
path.stroke(
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
startPoint: .topTrailing, endPoint: .bottomLeading),
style: StrokeStyle(lineWidth: r.lineWidth, lineCap: .round))
path.stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: r.lineWidth * 0.35, lineCap: .round))
.blur(radius: 1)
marker(at: r.from, color)
marker(at: r.to, color)
}
.frame(width: canvas.width, height: canvas.height)
}
private func marker(at pt: CGPoint, _ color: Color) -> some View {
ZStack {
Circle().fill(color.opacity(0.5)).frame(width: 60, height: 60).blur(radius: 14)
Circle().fill(color).frame(width: 30, height: 30)
Circle().fill(.white).frame(width: 14, height: 14)
}
.position(pt)
}
// Converging encryption beams: thin glowing lines fanning from the top phone's edge
// down to a single convergence point (above the lock).
private func beamsLayer(_ b: BeamsSpec) -> some View {
let color = Color(hex: b.color)
return Canvas { ctx, _ in
for i in 0..<b.count {
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.50.5
// Fan across the cross axis from the start line, converging on (cx, cy).
let start = b.horizontal
? CGPoint(x: b.y0, y: b.cy + CGFloat(f) * 2 * b.spread)
: CGPoint(x: b.cx + CGFloat(f) * 2 * b.spread, y: b.y0)
var path = Path()
path.move(to: start)
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
ctx.stroke(path, with: .color(color.opacity(op)),
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
}
}
.frame(width: canvas.width, height: canvas.height)
.blur(radius: 0.6)
.shadow(color: color.opacity(0.7), radius: 10)
}
// Vertical binary "protection" stream from the lock down to the receiving phone.
private func streamLayer(_ s: StreamSpec) -> some View {
let color = Color(hex: s.color)
return Canvas { ctx, _ in
let spacing = 44.0
let count = max(1, Int((s.y1 - s.y0) / spacing))
for i in 0...count {
let t = Double(i) / Double(count)
let along = s.y0 + (s.y1 - s.y0) * t
let at = s.horizontal ? CGPoint(x: along, y: s.cx) : CGPoint(x: s.cx, y: along)
let bit = (i % 3 == 0) ? "0" : "1"
// fade in near the lock and out near the receiving device
let op = 0.5 + 0.5 * sin(Double.pi * t)
var res = ctx.resolve(Text(bit)
.font(.system(size: 30, weight: .semibold, design: .monospaced)))
res.shading = .color(color.opacity(op))
ctx.draw(res, at: at)
}
}
.frame(width: canvas.width, height: canvas.height)
.shadow(color: color.opacity(0.6), radius: 8)
}
// Glowing padlock (SF Symbol) the encryption focal point.
private func lockLayer(_ l: LockSpec) -> some View {
let color = Color(hex: l.color)
return Image(systemName: "lock.fill")
.font(.system(size: l.size, weight: .regular))
.foregroundStyle(
LinearGradient(colors: [.white, color], startPoint: .top, endPoint: .bottom))
.shadow(color: color.opacity(0.9), radius: 30)
.shadow(color: color.opacity(0.6), radius: 60)
.position(x: l.cx, y: l.cy)
}
// Localized banner label (CHIFFREMENT / PROTECTION) from strings.json.
private func bannerLayer(_ b: Banner) -> some View {
let text = (b.kind == .encryption ? caption.encryption : caption.protection) ?? ""
return Text(text)
.font(.system(size: 48, weight: .bold))
.tracking(3)
.foregroundStyle(Color(hex: "#e7dcf7"))
.position(x: b.cx, y: b.cy)
}
@ViewBuilder
private func captionBlock(_ titleSize: CGFloat, _ subSize: CGFloat) -> some View {
let content = VStack(spacing: 10) {
Text(caption.title)
.font(.system(size: titleSize, weight: .heavy))
.foregroundStyle(titleColor)
Text(caption.subtitle)
.font(.system(size: subSize, weight: .semibold))
.foregroundStyle(subColor)
}
.multilineTextAlignment(.center)
.frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
if spec.headerBackdrop {
// The panel is the caption's background, so it grows with the content long
// translations (Russian, etc.) get a taller/wider backdrop automatically.
let shape = RoundedRectangle(cornerRadius: 48, style: .continuous)
content
.padding(.horizontal, 56)
.padding(.vertical, 44)
.background(shape.fill(Color(hex: "#180a30").opacity(0.82))
.overlay(shape.stroke(Color.white.opacity(0.16), lineWidth: 1.5)))
.shadow(color: .black.opacity(0.35), radius: 24, y: 10)
} else {
content
}
}
private var captionLayer: some View {
let top = spec.captionPlace == .top
// Keep the size for short captions, but shrink long translations (e.g. Russian /
// Polish / Portuguese wrap both lines) so the taller block still fits its band and
// never overlaps the phone. ViewThatFits picks the largest variant that fits.
// The top band is shorter than the bottom one because those screens' phones are
// large and start high, leaving less room above them.
let region: CGFloat = top ? (spec.headerBackdrop ? 560 : 290) : 360
let fitted = ViewThatFits(in: .vertical) {
captionBlock(104, 60)
captionBlock(92, 54)
captionBlock(82, 48)
captionBlock(72, 44)
}
.frame(width: canvas.width, height: region, alignment: top ? .top : .bottom)
return fitted
.padding(top ? .top : .bottom, top ? 96 : 110)
.frame(width: canvas.width, height: canvas.height,
alignment: top ? .top : .bottom)
}
}

View File

@@ -0,0 +1,302 @@
import SwiftUI
// Layout data for each marketing screen. Numbers are in pixels (the canvas renders
// at scale 1, so a value of 104 == 104px). Ported from the old screens.typ `screens`
// dict tune these against the originals.
extension Color {
// "#rrggbb"
init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let v = UInt64(s, radix: 16) ?? 0
self.init(
.sRGB,
red: Double((v >> 16) & 0xff) / 255,
green: Double((v >> 8) & 0xff) / 255,
blue: Double(v & 0xff) / 255,
opacity: 1)
}
}
enum CaptionPlace { case top, bottom }
enum CaptionTheme { case dark, light }
struct GradientSpec {
let stops: [String] // hex colors, topbottom / startend
let start: UnitPoint
let end: UnitPoint
var gradient: LinearGradient {
LinearGradient(colors: stops.map { Color(hex: $0) }, startPoint: start, endPoint: end)
}
}
// A 3D device layer. The screenshot is textured onto the model's screen and the phone
// is posed in real 3D (pitch/yaw/roll), positioned by its center on the canvas and
// sized by its upright height matching how the originals were laid out.
struct Pose {
var pitch: CGFloat = 0 // deg, about X (top tips away/toward viewer)
var yaw: CGFloat = 0 // deg, about Y (turn left/right, reveals a side edge)
var roll: CGFloat = 0 // deg, about Z (in-plane spin)
}
struct DeviceSpec {
var height: CGFloat // upright phone height in canvas px (drives scale)
var cx: CGFloat // phone center X on the 1284-wide canvas
var cy: CGFloat // phone center Y on the 2778-tall canvas
var pose: Pose = Pose()
var shadow: Bool = true // soft contact shadow under the phone
var blackScreen: Bool = false // texture the screen solid black (ignore the shot)
}
struct GlobeSpec {
var width: CGFloat
var dx: CGFloat
var dy: CGFloat
}
// A transfer route: two city markers (departure + arrival) on the globe joined by a
// glowing arc that swoops down and passes behind the phone "send from Paris to LA,
// through your phone". Generated natively.
struct RouteSpec {
var from: CGPoint // departure marker (on the globe)
var to: CGPoint // arrival marker (on the globe)
var c1: CGPoint // Bézier controls pull down to bulge the arc through the phone
var c2: CGPoint
var lineWidth: CGFloat = 12
var color: String = "#a855f7"
}
// Stay-private redesign: a vertical encryption flow between two stacked phones
// converging light beams (encryption) a glowing padlock a binary stream (protection).
struct BeamsSpec {
var cx: CGFloat // convergence x
var y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
var spread: CGFloat // half-extent the beams fan across at the start line
var cy: CGFloat // converge to this point
var count: Int = 22
var color: String = "#9ec3ff"
// Horizontal: beams fan across y from a vertical start line and converge leftright
// (for side-by-side devices) instead of fanning across x from a horizontal line.
var horizontal: Bool = false
}
struct LockSpec {
var cx: CGFloat
var cy: CGFloat
var size: CGFloat
var color: String = "#a9c9ff"
}
struct StreamSpec {
var cx: CGFloat // cross-axis centre (x for a vertical stream, y for a horizontal one)
var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
var y1: CGFloat // along-axis end (the receiving device)
var color: String = "#9ec3ff"
var horizontal: Bool = false // flow leftright instead of topbottom
}
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
enum BannerKind { case encryption, protection }
struct Banner {
var kind: BannerKind
var cx: CGFloat
var cy: CGFloat
}
struct ScreenSpec {
let id: String
let bg: GradientSpec
let captionPlace: CaptionPlace
let captionTheme: CaptionTheme
var globe: GlobeSpec? = nil
var route: RouteSpec? = nil
var device: DeviceSpec? = nil
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
var beams: BeamsSpec? = nil
var lock: LockSpec? = nil
var stream: StreamSpec? = nil
var banners: [Banner] = []
var headerBackdrop: Bool = false // dark scrim behind the top caption
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
// approval modal shot, matching the original.
var shotId: String? = nil
static func all(for platform: Platform) -> [String: ScreenSpec] {
switch platform {
case .iphone: iphone
case .ipad: ipad
case .mac: mac
}
}
static let iphone: [String: ScreenSpec] = [
// Straight-on hero, large and centered, showing the Share/QR sheet.
"share-securely": ScreenSpec(
id: "share-securely",
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(
height: 2300, cx: 642, cy: 1560,
pose: Pose(pitch: 0, yaw: 0, roll: 0))
),
// Tilted hero: turned to reveal the right edge, top sloping down-right.
"choose-receivers": ScreenSpec(
id: "choose-receivers",
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(
height: 2160, cx: 620, cy: 1620,
pose: Pose(pitch: -9, yaw: -14, roll: 7))
),
// Strongly tilted, lying diagonally in the lower-left; globe + orbit above.
"send-anywhere": ScreenSpec(
id: "send-anywhere",
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
captionPlace: .bottom, captionTheme: .dark,
globe: GlobeSpec(width: 1520, dx: -10, dy: -200),
route: RouteSpec(
from: CGPoint(x: 1284 - 289, y: 226), // departure Paris (Europe, right of globe)
to: CGPoint(x: 211, y: 296), // arrival Los Angeles (upper-left of globe)
c1: CGPoint(x: 1500, y: 1980), // pull the arc down through the phone
c2: CGPoint(x: -300, y: 1980),
lineWidth: 12),
device: DeviceSpec(
height: 1550, cx: 581, cy: 1600,
pose: Pose(pitch: 35, yaw: 35, roll: 15)),
shotId: "share-securely"
),
// Two partial phones with black screens + a generated binary data ribbon.
// Vertical encryption flow: two stacked centred phones, converging beams into a
// glowing padlock, then a binary "protection" stream down to the receiver.
"stay-private": ScreenSpec(
id: "stay-private",
bg: GradientSpec(
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"],
start: .top, end: .bottom),
captionPlace: .top, captionTheme: .light,
devices: [
DeviceSpec(
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
pose: Pose(roll: 180), shadow: false, blackScreen: true),
DeviceSpec(
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
pose: Pose(), shadow: false, blackScreen: true),
],
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
lock: LockSpec(cx: 642, cy: 1330, size: 300),
stream: StreamSpec(cx: 642, y0: 1520, y1: 2360),
banners: [
Banner(kind: .encryption, cx: 642, cy: 1120),
Banner(kind: .protection, cx: 642, cy: 1620),
],
headerBackdrop: true
),
]
// iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass same
// compositions as iPhone, retuned for the squarer, larger canvas.
static let ipad: [String: ScreenSpec] = [
"share-securely": ScreenSpec(
id: "share-securely",
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
),
"choose-receivers": ScreenSpec(
id: "choose-receivers",
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(
height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7))
),
"send-anywhere": ScreenSpec(
id: "send-anywhere",
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
captionPlace: .bottom, captionTheme: .dark,
globe: GlobeSpec(width: 2000, dx: 24, dy: -240),
route: RouteSpec(
from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423),
c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14),
device: DeviceSpec(
height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)),
shotId: "share-securely"
),
"stay-private": ScreenSpec(
id: "stay-private",
bg: GradientSpec(
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .light,
devices: [
DeviceSpec(
height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false,
blackScreen: true),
DeviceSpec(
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
),
],
beams: BeamsSpec(cx: 1024, y0: 720, spread: 200, cy: 1220, count: 24),
lock: LockSpec(cx: 1024, cy: 1400, size: 320),
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
banners: [
Banner(kind: .encryption, cx: 1024, cy: 1200),
Banner(kind: .protection, cx: 1024, cy: 1680),
],
headerBackdrop: true
),
]
// MacBook layouts (canvas 2880x1800, landscape, centre x = 1440). First pass.
static let mac: [String: ScreenSpec] = [
"share-securely": ScreenSpec(
id: "share-securely",
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
),
"choose-receivers": ScreenSpec(
id: "choose-receivers",
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
),
// Globe backdrop in the upper half, route arcing through a centred laptop, caption
// at the bottom. Reuses the share capture (matches the other platforms).
"send-anywhere": ScreenSpec(
id: "send-anywhere",
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
captionPlace: .bottom, captionTheme: .light,
globe: GlobeSpec(width: 1320, dx: 780, dy: -150),
route: RouteSpec(
from: CGPoint(x: 1650, y: 222), to: CGPoint(x: 976, y: 274),
c1: CGPoint(x: 5200, y: 1150), c2: CGPoint(x: -2000, y: 1150), lineWidth: 13),
device: DeviceSpec(height: 1300, cx: 1440, cy: 960, pose: Pose()),
headerBackdrop: true,
shotId: "share-securely"
),
// Vertical encryption flow between two stacked laptops (same structure as the phone
// layouts, retuned for the landscape canvas).
"stay-private": ScreenSpec(
id: "stay-private",
bg: GradientSpec(
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .light,
// Two laptops on the left and right; encryption flows horizontally between them:
// beams converge leftlock, a binary stream runs lockright.
devices: [
DeviceSpec(
height: 1040, cx: 120, cy: 1000, pose: Pose(), shadow: false, blackScreen: true),
DeviceSpec(
height: 1040, cx: 2760, cy: 1000, pose: Pose(), shadow: false, blackScreen: true
),
],
beams: BeamsSpec(cx: 1290, y0: 760, spread: 300, cy: 1000, count: 26, horizontal: true),
lock: LockSpec(cx: 1440, cy: 1000, size: 300),
stream: StreamSpec(cx: 1000, y0: 1600, y1: 2140, horizontal: true),
banners: [
Banner(kind: .encryption, cx: 940, cy: 1360),
Banner(kind: .protection, cx: 1960, cy: 1360),
]
),
]
}

View File

@@ -0,0 +1,51 @@
import Foundation
// Mirrors strings.json. Captions live only here (not app strings).
struct Caption: Decodable {
let title: String
let subtitle: String
// Optional in-composition banner labels (stay-private): CHIFFREMENT / PROTECTION.
let encryption: String?
let protection: String?
}
struct LocaleStrings: Decodable {
let folder: String
private let screens: [String: Caption]
subscript(_ screen: String) -> Caption? { screens[screen] }
private enum CodingKeys: String, CodingKey { case folder = "_folder" }
init(from decoder: Decoder) throws {
// `_folder` is a reserved key; every other key is a screen id -> Caption.
let dyn = try decoder.container(keyedBy: DynamicKey.self)
var acc: [String: Caption] = [:]
var folderName = ""
for key in dyn.allKeys {
if key.stringValue == "_folder" {
folderName = try dyn.decode(String.self, forKey: key)
} else if let cap = try? dyn.decode(Caption.self, forKey: key) {
acc[key.stringValue] = cap
}
}
self.folder = folderName
self.screens = acc
}
private struct DynamicKey: CodingKey {
var stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
}
struct Strings: Decodable {
let screens: [String]
let locales: [String: LocaleStrings]
static func load(_ url: URL) throws -> Strings {
try JSONDecoder().decode(Strings.self, from: Data(contentsOf: url))
}
}

View File

@@ -0,0 +1,111 @@
import AppKit
import SwiftUI
// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer.
//
// swift run studio # -> generated/<Language>/<Name>.png
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
// LOCALES="fr de" SCREENS="share-securely" swift run studio
//
// Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
// ./capture.sh) transient build output, regenerated per run, never committed. The 3D
// device models + globe come from assets/. Run from the studio directory.
// Screen id -> output file basename (matches the existing App Store filenames).
let nameFor: [String: String] = [
"choose-receivers": "Choose Receivers", "send-anywhere": "Send Anywhere",
"share-securely": "Share Securely", "stay-private": "Stay private",
]
func env(_ key: String, _ fallback: String) -> String {
let v = ProcessInfo.processInfo.environment[key]
return (v?.isEmpty == false) ? v! : fallback
}
func loadNSImage(_ path: String) -> (NSImage, CGSize)? {
guard let ns = NSImage(contentsOfFile: path) else { return nil }
let size = ns.representations.first.map { CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) } ?? ns.size
return (ns, size)
}
@MainActor
func writePNG(_ view: some View, to url: URL) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = 1
guard let cg = renderer.cgImage else {
throw NSError(domain: "studio", code: 1, userInfo: [NSLocalizedDescriptionKey: "render failed"])
}
let rep = NSBitmapImageRep(cgImage: cg)
rep.size = NSSize(width: cg.width, height: cg.height)
guard let data = rep.representation(using: .png, properties: [:]) else {
throw NSError(domain: "studio", code: 2, userInfo: [NSLocalizedDescriptionKey: "png encode failed"])
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url)
}
@MainActor
func run() throws {
let args = CommandLine.arguments
let publish = args.contains("--publish")
let cwd = FileManager.default.currentDirectoryPath
let base = URL(fileURLWithPath: cwd)
let strings = try Strings.load(base.appendingPathComponent("strings.json"))
let locales = env("LOCALES", "en fr de es it nl pl pt ru").split(separator: " ").map(String.init)
let screens = env("SCREENS", "choose-receivers send-anywhere share-securely stay-private")
.split(separator: " ").map(String.init)
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
// Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
let canvas = platform.canvas
let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
? SceneKitDeviceRenderer(model: model) : nil
if device == nil {
FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
}
let specs = ScreenSpec.all(for: platform)
var count = 0
for loc in locales {
guard let ls = strings.locales[loc] else {
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
}
// iPhone -> <Language>/, iPad -> <Language>/iPad/ so the sets stay separate.
var outDir = publish
? base.appendingPathComponent("../\(ls.folder)").standardized
: base.appendingPathComponent("generated/\(ls.folder)")
if !platform.outputSubfolder.isEmpty {
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
}
for scr in screens {
guard let spec = specs[scr], let caption = ls[scr] else {
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
}
let shotId = spec.shotId ?? scr
let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)")
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
let hasDevice = spec.device != nil || !spec.devices.isEmpty
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
device: hasDevice ? device : nil, canvas: canvas)
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
try writePNG(frame, to: out)
print("\(out.path)")
count += 1
}
}
print("\nDone — \(count) screenshot(s)\(publish ? " (published)" : "").")
}
// ImageRenderer needs an AppKit context for text/font resolution.
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
do { try MainActor.assumeIsolated { try run() } }
catch { FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)); exit(1) }

View File

@@ -0,0 +1,22 @@
# Third-party asset attributions
## iphone-17-pro-max.usdz
- **Title:** iphone 17 pro max silver
- **Author:** TechFreak (Sketchfab)
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
## ipad-pro.usdz
- **Title:** 2018 Apple iPad Pro
- **Author:** lazercar (Sketchfab)
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
## macbook-air.usdz
- **Title:** MacBook Air
- **Author:** Jakob (Sketchfab — https://sketchfab.com/jakob3dlindblom)
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
CC BY 4.0 requires visible credit wherever these models (or renders derived from them)
are published. The App Store screenshots produced by this studio are derivatives, so
keep this attribution with the project. If a suitable place exists (e.g. the app's
acknowledgements / licenses screen), surface the credit there as well.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

120
packaging/apple/studio/capture.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Capture real, localized app screens for the studio pipeline.
#
# Drives the app straight into each marketing screen via the DEBUG `-VniScreenshot`
# launch argument (no UI automation needed), once per locale via `-AppleLanguages`,
# and writes assets/shots/<locale>/<screen>.png at the device's native resolution.
#
# ./capture.sh # all locales
# LOCALES="en fr" ./capture.sh
# SCREENSHOT_DEVICE="iPhone 17 Pro Max" ./capture.sh
#
# Requires a Debug build (the fixture gateway is compiled under #if DEBUG).
set -euo pipefail
trap 'echo "capture.sh: failed (rc=$?) at line $LINENO" >&2' ERR
cd "$(dirname "$0")"
APPLE_DIR="$(cd ../../../apple && pwd)"
BUNDLE_ID="com.vnidrop.app"
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
# PLATFORM=iphone|ipad selects the simulator and the shots subdir (must match main.swift).
PLATFORM="${PLATFORM:-iphone}"
case "$PLATFORM" in
ipad) DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}";;
*) DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}";;
esac
# scenario (launch arg value) -> studio screen id (output filename stem).
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
# captured separately; stay-private uses black screens (no capture).
SCENARIOS="share:share-securely approval:choose-receivers"
# Screenshots are transient build output, regenerated per run — never committed. They
# live under generated/ (git-ignored), not in assets/.
SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}"
# ---- macOS: no simulator. Build the native app, drive it with the fixture args, size its
# window to 16:10 and grab it with screencapture. Needs Accessibility + Screen Recording
# permission granted to the terminal (you'll be prompted the first time).
if [ "$PLATFORM" = "mac" ]; then
WIN_X=60; WIN_Y=80; WIN_W=1440; WIN_H=900 # 16:10, matches the MacBook screen aspect
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Building VniDrop (Debug) for macOS"
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination 'platform=macOS' -configuration Debug CODE_SIGNING_ALLOWED=NO >/dev/null
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination 'platform=macOS' -configuration Debug -showBuildSettings 2>/dev/null \
| awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
EXE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null || echo VniDrop)"
EXE="$APP/Contents/MacOS/$EXE_NAME"
echo " app: $APP (exe: $EXE_NAME)"
echo " (grant Accessibility + Screen Recording to your terminal if prompted)"
for loc in $LOCALES; do
mkdir -p "$SHOTS_DIR/$loc"
for pair in $SCENARIOS; do
scenario="${pair%%:*}"; screen="${pair##*:}"
pkill -x "$EXE_NAME" 2>/dev/null || true; sleep 1
# Launch the binary directly (avoids LaunchServices -1712 for DerivedData apps).
"$EXE" -VniScreenshot "$scenario" \
-AppleLanguages "($loc)" -AppleLocale "$loc" -AppleInterfaceStyle Dark &
sleep 4
osascript >/dev/null 2>&1 <<-OSA || true
tell application "System Events" to tell process "$EXE_NAME"
set frontmost to true
set position of front window to {$WIN_X, $WIN_Y}
set size of front window to {$WIN_W, $WIN_H}
end tell
OSA
sleep 1
tmp="$(mktemp -t vnishot).png"
screencapture -x -R${WIN_X},${WIN_Y},${WIN_W},${WIN_H} "$tmp"
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
echo " 🖥️ $SHOTS_DIR/$loc/$screen.png"
done
done
pkill -x "$EXE_NAME" 2>/dev/null || true
echo ""; echo "Done. Now run 'PLATFORM=mac swift run studio' to composite."
exit 0
fi
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Building VniDrop (Debug) for $DEVICE"
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
CODE_SIGNING_ALLOWED=NO >/dev/null
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
-showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
UDID="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1 \
| grep -oiE '[0-9a-f-]{36}' | head -1)"
[ -n "$UDID" ] || { echo "error: no simulator '$DEVICE'"; exit 1; }
echo " device: $UDID"
echo " app: $APP"
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
xcrun simctl ui "$UDID" appearance dark >/dev/null 2>&1 || true # match the dark marketing look
xcrun simctl status_bar "$UDID" override --time "9:41" \
--batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3 >/dev/null 2>&1 || true
xcrun simctl install "$UDID" "$APP"
for loc in $LOCALES; do
mkdir -p "$SHOTS_DIR/$loc"
for pair in $SCENARIOS; do
scenario="${pair%%:*}"; screen="${pair##*:}"
xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
-VniScreenshot "$scenario" -AppleLanguages "($loc)" -AppleLocale "$loc" >/dev/null
sleep 4
# simctl can't write into the project tree (TCC blocks the CoreSimulator helper
# on external/again-protected volumes), so capture to a temp file and move it in.
tmp="$(mktemp -t vnishot).png"
xcrun simctl io "$UDID" screenshot "$tmp" >/dev/null 2>&1
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
echo " 📸 $SHOTS_DIR/$loc/$screen.png"
done
done
echo ""
echo "Done. Now run 'swift run studio' to composite."

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# One-shot: capture fresh per-locale screenshots from the app, then composite every
# marketing screen. Screenshots are transient (generated/shots, git-ignored) and are
# regenerated each run — never committed.
#
# ./generate.sh # capture + composite -> generated/<Language>/
# ./generate.sh --publish # capture + composite -> ../<Language>/ (ships)
# LOCALES="en fr" ./generate.sh # subset of locales
#
# During layout iteration you don't need to re-capture every time: run `./capture.sh`
# once, then `swift run studio` on its own (it reads the existing generated/shots).
set -euo pipefail
cd "$(dirname "$0")"
./capture.sh
swift run studio "$@"

View File

@@ -0,0 +1,200 @@
{
"_comment": "Marketing captions for App Store screenshots. NOT app strings — these live only here. Translations below the English source are a first pass and should be reviewed by a native speaker.",
"screens": [
"choose-receivers",
"send-anywhere",
"share-securely",
"stay-private"
],
"locales": {
"en": {
"_folder": "English",
"choose-receivers": {
"title": "Choose Receivers",
"subtitle": "Approve each person invited"
},
"send-anywhere": {
"title": "Send Anywhere",
"subtitle": "Direct file transfer worldwide"
},
"share-securely": {
"title": "Share Securely",
"subtitle": "QR, NFC or file"
},
"stay-private": {
"title": "Stay Private",
"subtitle": "Encrypted with no server copy",
"encryption": "ENCRYPTION",
"protection": "PROTECTION"
}
},
"fr": {
"_folder": "French",
"choose-receivers": {
"title": "Choisissez les destinataires",
"subtitle": "Approuvez chaque personne invitée"
},
"send-anywhere": {
"title": "Envoyez partout",
"subtitle": "Transfert direct dans le monde entier"
},
"share-securely": {
"title": "Partagez en sécurité",
"subtitle": "QR, NFC ou fichier"
},
"stay-private": {
"title": "Restez privé",
"subtitle": "Chiffré, sans copie serveur",
"encryption": "CHIFFREMENT",
"protection": "PROTECTION"
}
},
"de": {
"_folder": "German",
"choose-receivers": {
"title": "Empfänger auswählen",
"subtitle": "Jede eingeladene Person bestätigen"
},
"send-anywhere": {
"title": "Überallhin senden",
"subtitle": "Direkte Dateiübertragung weltweit"
},
"share-securely": {
"title": "Sicher teilen",
"subtitle": "QR, NFC oder Datei"
},
"stay-private": {
"title": "Privat bleiben",
"subtitle": "Verschlüsselt, ohne Server-Kopie",
"encryption": "VERSCHLÜSSELUNG",
"protection": "SCHUTZ"
}
},
"es": {
"_folder": "Spanish",
"choose-receivers": {
"title": "Elige destinatarios",
"subtitle": "Aprueba a cada invitado"
},
"send-anywhere": {
"title": "Envía a cualquier lugar",
"subtitle": "Transferencia directa en todo el mundo"
},
"share-securely": {
"title": "Comparte con seguridad",
"subtitle": "QR, NFC o archivo"
},
"stay-private": {
"title": "Mantén la privacidad",
"subtitle": "Cifrado, sin copia en servidor",
"encryption": "CIFRADO",
"protection": "PROTECCIÓN"
}
},
"it": {
"_folder": "Italian",
"choose-receivers": {
"title": "Scegli i destinatari",
"subtitle": "Approva ogni invitato"
},
"send-anywhere": {
"title": "Invia ovunque",
"subtitle": "Trasferimento diretto in tutto il mondo"
},
"share-securely": {
"title": "Condividi in sicurezza",
"subtitle": "QR, NFC o file"
},
"stay-private": {
"title": "Resta privato",
"subtitle": "Crittografato, senza copia sul server",
"encryption": "CRITTOGRAFIA",
"protection": "PROTEZIONE"
}
},
"nl": {
"_folder": "Dutch",
"choose-receivers": {
"title": "Kies ontvangers",
"subtitle": "Keur elke genodigde goed"
},
"send-anywhere": {
"title": "Verstuur overal",
"subtitle": "Directe bestandsoverdracht wereldwijd"
},
"share-securely": {
"title": "Deel veilig",
"subtitle": "QR, NFC of bestand"
},
"stay-private": {
"title": "Blijf privé",
"subtitle": "Versleuteld, geen serverkopie",
"encryption": "VERSLEUTELING",
"protection": "BESCHERMING"
}
},
"pl": {
"_folder": "Polish",
"choose-receivers": {
"title": "Wybierz odbiorców",
"subtitle": "Zatwierdź każdą zaproszoną osobę"
},
"send-anywhere": {
"title": "Wysyłaj wszędzie",
"subtitle": "Bezpośredni transfer plików na całym świecie"
},
"share-securely": {
"title": "Udostępniaj bezpiecznie",
"subtitle": "Kod QR, NFC lub plik"
},
"stay-private": {
"title": "Zachowaj prywatność",
"subtitle": "Szyfrowane, bez kopii na serwerze",
"encryption": "SZYFROWANIE",
"protection": "OCHRONA"
}
},
"pt": {
"_folder": "Portuguese",
"choose-receivers": {
"title": "Escolha os destinatários",
"subtitle": "Aprove cada pessoa convidada"
},
"send-anywhere": {
"title": "Envie para qualquer lugar",
"subtitle": "Transferência direta pelo mundo todo"
},
"share-securely": {
"title": "Compartilhe com segurança",
"subtitle": "QR, NFC ou arquivo"
},
"stay-private": {
"title": "Mantenha a privacidade",
"subtitle": "Criptografado, sem cópia no servidor",
"encryption": "CRIPTOGRAFIA",
"protection": "PROTEÇÃO"
}
},
"ru": {
"_folder": "Russian",
"choose-receivers": {
"title": "Выбирайте получателей",
"subtitle": "Подтверждайте каждого приглашённого"
},
"send-anywhere": {
"title": "Отправляйте куда угодно",
"subtitle": "Прямая передача файлов по всему миру"
},
"share-securely": {
"title": "Делитесь безопасно",
"subtitle": "QR, NFC или файл"
},
"stay-private": {
"title": "Оставайтесь приватными",
"subtitle": "Шифрование без копии на сервере",
"encryption": "ШИФРОВАНИЕ",
"protection": "ЗАЩИТА"
}
}
}
}

View File

@@ -1,13 +1,13 @@
# VniDrop diagnostics API # VniDrop diagnostics API
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs. metadata; R2 stores the larger attached logs.
The service is designed for modest traffic and low operating cost: The service is designed for modest traffic and low operating cost:
- one D1 row is written per telemetry batch, not per event; - one D1 row is written per bug report;
- crash stacks and bug logs are stored in R2 instead of D1; - bug logs are stored in R2 instead of D1;
- request and batch limits reject oversized work before storage writes; - request limits reject oversized work before storage writes;
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention; - an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
- no Queue, Durable Object, or KV resources are required. - no Queue, Durable Object, or KV resources are required.
@@ -35,15 +35,13 @@ X-VniDrop-Install-Id: <anonymous install UUID>
|--------|------|------| |--------|------|------|
| `GET` | `/live` | process liveness; does not touch storage | | `GET` | `/live` | process liveness; does not touch storage |
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema | | `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
| `POST` | `/v1/crashes` | app crash payload |
| `POST` | `/v1/bugs` | app bug-report payload | | `POST` | `/v1/bugs` | app bug-report payload |
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID Report IDs are client-generated UUIDs. A client must reuse the same ID when
when retrying so D1 can acknowledge the request without storing it twice. retrying so D1 can acknowledge the request without storing it twice.
Accepted reports return `202`. Defaults are a 262,144-byte request limit and at Accepted reports return `202`. The default is a 262,144-byte request limit.
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per Cloudflare rate-limit bindings allow 30 requests per
installation and 120 requests per source, per ingest route, per minute. Source installation and 120 requests per source, per ingest route, per minute. Source
limits run before shared-key verification so rejected traffic is bounded too. limits run before shared-key verification so rejected traffic is bounded too.
These counters are eventually consistent and local to a Cloudflare location, so These counters are eventually consistent and local to a Cloudflare location, so
@@ -153,11 +151,11 @@ migrations to the isolated local database assigned to each test file.
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at `RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
17 minutes past every hour. Cleanup works in bounded batches: it deletes each 17 minutes past every hour. Cleanup works in bounded batches: it deletes each
expired report's referenced R2 object before deleting that exact D1 row. The R2 expired report's referenced R2 object before deleting that exact D1 row. The R2
lifecycle rule is an independent backstop for stack and log objects, including lifecycle rule is an independent backstop for log objects, including objects left
objects left behind by a partial ingest failure. Each scheduled run can remove behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows
8,000 event batches and 7,200 rows from each report table while staying below while staying below D1's per-invocation query ceiling. Later hourly runs continue
D1's per-invocation query ceiling. Later hourly runs continue any backlog. any backlog.
Reaching the cap emits a structured warning with the remaining expired-row counts; Reaching the cap emits a structured warning with the remaining expired-row count;
alert on that warning because alert on that warning because
retention is necessarily best-effort during sustained distributed abuse. retention is necessarily best-effort during sustained distributed abuse.
@@ -179,23 +177,20 @@ vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>
Both the endpoint and key are required. When both are empty the app uses its Both the endpoint and key are required. When both are empty the app uses its
offline-safe no-op transport; configuring only one fails the Gradle build. offline-safe no-op transport; configuring only one fails the Gradle build.
`vnidrop.diagnostics.included=false` disables `vnidrop.diagnostics.included=false` routes bug reports to that no-op transport
automatic telemetry and crash upload, but a configured endpoint can still accept (never sent); a configured endpoint accepts an explicit user-submitted bug report.
an explicit user-submitted bug report. Treat the app-side key as an abuse-control Treat the app-side key as an abuse-control token with the limitations described
token with the limitations described above. above.
## Reading reports ## Reading reports
```bash ```bash
npx wrangler d1 execute vnidrop-diagnostics --remote \
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
npx wrangler d1 execute vnidrop-diagnostics --remote \ npx wrangler d1 execute vnidrop-diagnostics --remote \
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20" --command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
``` ```
R2 object keys use `crashes/<id>/<attempt-id>/stack.txt` and R2 object keys use `bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry prevents a retry from overwriting an already accepted object before D1 detects
from overwriting an already accepted object before D1 detects the duplicate. the duplicate.
There is no public administration endpoint; inspect reports through authenticated There is no public administration endpoint; inspect reports through authenticated
Cloudflare tools or a future Access-protected dashboard. Cloudflare tools or a future Access-protected dashboard.

View File

@@ -0,0 +1,10 @@
-- Telemetry and crash auto-reporting were removed from the app; only user-initiated
-- bug reports remain. Drop the now-unused ingestion tables and their indexes.
DROP INDEX IF EXISTS idx_event_batches_received;
DROP INDEX IF EXISTS idx_event_batches_install;
DROP TABLE IF EXISTS event_batches;
DROP INDEX IF EXISTS idx_crashes_received;
DROP INDEX IF EXISTS idx_crashes_fingerprint;
DROP INDEX IF EXISTS idx_crashes_install;
DROP TABLE IF EXISTS crashes;

View File

@@ -1,20 +1,15 @@
import { import {
normalizeBug, normalizeBug,
normalizeCrash,
normalizeEvents,
readJsonObject, readJsonObject,
} from "./input"; } from "./input";
import { import {
type DiagnosticsEnv, type DiagnosticsEnv,
runRetention, runRetention,
storeBug, storeBug,
storeCrash,
storeEvents,
} from "./storage"; } from "./storage";
const DEFAULT_MAX_BODY_BYTES = 262_144; const DEFAULT_MAX_BODY_BYTES = 262_144;
const HARD_MAX_BODY_BYTES = 1_048_576; const HARD_MAX_BODY_BYTES = 1_048_576;
const DEFAULT_MAX_EVENTS = 50;
export default { export default {
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
@@ -72,41 +67,6 @@ export default {
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId); if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
switch (url.pathname) { switch (url.pathname) {
case "/v1/events": {
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
const normalized = normalizeEvents(parsed.value, maxEvents);
if (!normalized.ok) {
return json({ error: normalized.error }, normalized.status, requestId);
}
const result = await storeEvents(normalized.value, env);
return json(
{
ok: true,
id: result.id,
stored: result.stored,
duplicate: result.duplicate,
},
202,
requestId,
);
}
case "/v1/crashes": {
const normalized = normalizeCrash(parsed.value);
if (!normalized.ok) {
return json({ error: normalized.error }, normalized.status, requestId);
}
const result = await storeCrash(normalized.value, env);
return json(
{
ok: true,
id: result.id,
fingerprint: result.fingerprint,
duplicate: result.duplicate,
},
202,
requestId,
);
}
case "/v1/bugs": { case "/v1/bugs": {
const normalized = normalizeBug(parsed.value); const normalized = normalizeBug(parsed.value);
if (!normalized.ok) { if (!normalized.ok) {
@@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Respon
} }
try { try {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare(
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
),
env.DB.prepare(
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
),
env.DB.prepare( env.DB.prepare(
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1", "SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
), ),
@@ -216,8 +170,8 @@ async function installRateLimited(
return !result.success; return !result.success;
} }
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" { function isIngestPath(path: string): path is "/v1/bugs" {
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs"; return path === "/v1/bugs";
} }
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> { async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {

View File

@@ -8,21 +8,6 @@ export type InputFailure = {
export type InputResult<T> = { ok: true; value: T } | InputFailure; export type InputResult<T> = { ok: true; value: T } | InputFailure;
export type NormalizedProperties = Record<string, string>;
export interface NormalizedEvent {
name: string;
timestampMillis: number;
properties: NormalizedProperties;
schemaVersion: 1;
}
export interface NormalizedBreadcrumb {
name: string;
timestampMillis: number;
properties: NormalizedProperties;
}
export interface NormalizedDevice { export interface NormalizedDevice {
deviceName: string; deviceName: string;
deviceModel: string; deviceModel: string;
@@ -31,28 +16,6 @@ export interface NormalizedDevice {
batteryLevel: string; batteryLevel: string;
} }
export interface NormalizedEventsPayload {
batchId: string;
installId: string;
appVersion: string;
platform: string;
events: NormalizedEvent[];
}
export interface NormalizedCrashPayload {
id: string;
installId: string;
appVersion: string;
platform: string;
exceptionType: string;
exceptionMessage: string;
stackTrace: string;
occurredAt: number;
diagnosticsEnabledAtCapture: boolean;
breadcrumbs: NormalizedBreadcrumb[];
schemaVersion: 1;
}
export interface NormalizedBugPayload { export interface NormalizedBugPayload {
id: string; id: string;
installId: string; installId: string;
@@ -65,16 +28,12 @@ export interface NormalizedBugPayload {
contact: string; contact: string;
logs: string; logs: string;
device: NormalizedDevice; device: NormalizedDevice;
breadcrumbs: NormalizedBreadcrumb[];
schemaVersion: 1; schemaVersion: 1;
} }
export const MAX_LOG_BYTES = 192 * 1024; export const MAX_LOG_BYTES = 192 * 1024;
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
export const MAX_DEVICE_JSON_BYTES = 4_000; export const MAX_DEVICE_JSON_BYTES = 4_000;
const MAX_PROPERTIES = 12;
const MAX_BREADCRUMBS = 40;
const MISSING = Symbol("missing"); const MISSING = Symbol("missing");
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UTF8_ENCODER = new TextEncoder(); const UTF8_ENCODER = new TextEncoder();
@@ -164,119 +123,6 @@ export async function readJsonObject(
return success(parsed); return success(parsed);
} }
export function normalizeEvents(
body: JsonObject,
maxEvents = 50,
): InputResult<NormalizedEventsPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
throw new RangeError("maxEvents must be a positive safe integer");
}
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
if (!batchId.ok) return batchId;
const installId = installIdField(body);
if (!installId.ok) return installId;
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
if (!appVersion.ok) return appVersion;
const platform = stringField(body, ["platform"], 40, "invalid_platform");
if (!platform.ok) return platform;
const batchSchema = schemaVersion(body);
if (!batchSchema.ok) return batchSchema;
const rawEvents = pick(body, ["events"]);
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
if (rawEvents.length === 0) return failure(400, "empty_batch");
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
const events: NormalizedEvent[] = [];
for (const rawEvent of rawEvents) {
const event = normalizeEvent(rawEvent);
if (!event.ok) return event;
events.push(event.value);
}
return success({
batchId: batchId.value,
installId: installId.value,
appVersion: appVersion.value,
platform: platform.value,
events,
});
}
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
const id = idField(body, ["id"], "invalid_id");
if (!id.ok) return id;
const installId = installIdField(body);
if (!installId.ok) return installId;
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
if (!appVersion.ok) return appVersion;
const platform = stringField(body, ["platform"], 40, "invalid_platform");
if (!platform.ok) return platform;
const exceptionType = stringField(
body,
["exceptionType", "exception_type"],
120,
"invalid_exception_type",
true,
true,
);
if (!exceptionType.ok) return exceptionType;
const exceptionMessage = stringField(
body,
["exceptionMessage", "exception_message"],
2_000,
"invalid_exception_message",
true,
);
if (!exceptionMessage.ok) return exceptionMessage;
const stackTrace = stringField(
body,
["stackTrace", "stack_trace"],
32_000,
"invalid_stack_trace",
true,
);
if (!stackTrace.ok) return stackTrace;
const occurredAt = timestampField(
body,
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
);
if (!occurredAt.ok) return occurredAt;
const diagnosticsEnabled = booleanField(
body,
[
"diagnosticsEnabledAtCapture",
"diagnostics_enabled_at_capture",
"diagnostics_enabled",
],
"invalid_diagnostics_enabled",
true,
);
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
const version = schemaVersion(body);
if (!version.ok) return version;
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
if (!breadcrumbs.ok) return breadcrumbs;
return success({
id: id.value,
installId: installId.value,
appVersion: appVersion.value,
platform: platform.value,
exceptionType: exceptionType.value,
exceptionMessage: exceptionMessage.value,
stackTrace: stackTrace.value,
occurredAt: occurredAt.value,
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
breadcrumbs: breadcrumbs.value,
schemaVersion: version.value,
});
}
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> { export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body"); if (!isPlainObject(body)) return failure(400, "invalid_body");
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
if (!logs.ok) return logs; if (!logs.ok) return logs;
const device = normalizeDevice(pick(body, ["device"])); const device = normalizeDevice(pick(body, ["device"]));
if (!device.ok) return device; if (!device.ok) return device;
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
if (!breadcrumbs.ok) return breadcrumbs;
const version = schemaVersion(body); const version = schemaVersion(body);
if (!version.ok) return version; if (!version.ok) return version;
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
contact: contact.value, contact: contact.value,
logs: includeLogs.value === true ? logs.value : "", logs: includeLogs.value === true ? logs.value : "",
device: device.value, device: device.value,
breadcrumbs: breadcrumbs.value,
schemaVersion: version.value, schemaVersion: version.value,
}); });
} }
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
if (!isPlainObject(raw)) return failure(400, "invalid_event");
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
if (!name.ok) return name;
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
if (!timestamp.ok) return failure(400, "invalid_event");
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
if (!properties.ok) return properties;
const version = schemaVersion(raw);
if (!version.ok) return version;
return success({
name: name.value,
timestampMillis: timestamp.value,
properties: properties.value,
schemaVersion: version.value,
});
}
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
if (raw === MISSING) return success([]);
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
const breadcrumbs: NormalizedBreadcrumb[] = [];
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
if (!name.ok) return name;
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
const properties = normalizeProperties(
pick(item, ["properties", "props"]),
"invalid_breadcrumbs",
);
if (!properties.ok) return properties;
breadcrumbs.push({
name: name.value,
timestampMillis: timestamp.value,
properties: properties.value,
});
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
breadcrumbs.pop();
break;
}
}
return success(breadcrumbs);
}
function normalizeProperties(
raw: unknown | typeof MISSING,
error: string,
): InputResult<NormalizedProperties> {
if (raw === MISSING) return success({});
if (!isPlainObject(raw)) return failure(400, error);
const entries: Array<[string, string]> = [];
const normalizedKeys = new Set<string>();
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
if (typeof value !== "string") return failure(400, error);
const normalizedKey = truncateUtf8(key, 40);
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
return failure(400, error);
}
normalizedKeys.add(normalizedKey);
entries.push([normalizedKey, truncateUtf8(value, 128)]);
}
return success(Object.fromEntries(entries));
}
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> { function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
if (raw === MISSING) raw = {}; if (raw === MISSING) raw = {};
if (!isPlainObject(raw)) return failure(400, "invalid_device"); if (!isPlainObject(raw)) return failure(400, "invalid_device");

View File

@@ -1,12 +1,7 @@
import type { import type { NormalizedBugPayload } from "./input";
NormalizedBugPayload,
NormalizedCrashPayload,
NormalizedEventsPayload,
} from "./input";
export type DiagnosticsEnv = Cloudflare.Env & { export type DiagnosticsEnv = Cloudflare.Env & {
INGEST_KEY?: string; INGEST_KEY?: string;
AE?: AnalyticsEngineDataset;
}; };
export interface StoreResult { export interface StoreResult {
@@ -15,130 +10,6 @@ export interface StoreResult {
stored: number; stored: number;
} }
export async function storeEvents(
payload: NormalizedEventsPayload,
env: DiagnosticsEnv,
): Promise<StoreResult> {
const result = await env.DB.prepare(
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`,
)
.bind(
payload.batchId,
Date.now(),
payload.installId,
payload.appVersion,
payload.platform,
payload.events.length,
JSON.stringify(payload.events),
)
.run();
const duplicate = result.meta.changes === 0;
if (!duplicate && env.AE) {
try {
for (const event of payload.events) {
env.AE.writeDataPoint({
blobs: [
event.name,
payload.platform,
payload.appVersion,
payload.installId,
JSON.stringify(event.properties),
payload.batchId,
],
doubles: [event.timestampMillis, event.schemaVersion],
indexes: [payload.installId],
});
}
} catch (error) {
// D1 remains the durable source of truth if the optional analytics index is unavailable.
console.error(
JSON.stringify({
message: "failed to index diagnostics event batch",
batchId: payload.batchId,
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
return {
id: payload.batchId,
duplicate,
stored: duplicate ? 0 : payload.events.length,
};
}
export async function storeCrash(
payload: NormalizedCrashPayload,
env: DiagnosticsEnv,
): Promise<StoreResult & { fingerprint: string }> {
const database = env.DB.withSession("first-primary");
const existing = await database
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
.bind(payload.id)
.first<{ fingerprint: string }>();
if (existing) {
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
}
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
const stackKey = payload.stackTrace
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
: null;
if (stackKey) {
await env.BLOBS.put(stackKey, payload.stackTrace, {
httpMetadata: { contentType: "text/plain; charset=utf-8" },
customMetadata: { installId: payload.installId, fingerprint },
});
}
try {
const result = await database
.prepare(
`INSERT INTO crashes (
id, received_at, occurred_at, install_id, app_version, platform,
exception_type, exception_message, fingerprint, diagnostics_enabled,
stack_r2_key, breadcrumbs_json, schema_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`,
)
.bind(
payload.id,
Date.now(),
payload.occurredAt,
payload.installId,
payload.appVersion,
payload.platform,
payload.exceptionType,
payload.exceptionMessage,
fingerprint,
payload.diagnosticsEnabledAtCapture ? 1 : 0,
stackKey,
JSON.stringify(payload.breadcrumbs),
payload.schemaVersion,
)
.run();
const duplicate = result.meta.changes === 0;
if (duplicate) {
const stored = await database
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
.bind(payload.id)
.first<{ fingerprint: string }>();
if (!stored) throw new Error("duplicate crash row was not readable");
if (stackKey) await deleteAttemptBlob(env, stackKey);
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
}
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
} catch (error) {
if (stackKey) {
await deleteAttemptBlob(env, stackKey);
}
throw error;
}
}
export async function storeBug( export async function storeBug(
payload: NormalizedBugPayload, payload: NormalizedBugPayload,
env: DiagnosticsEnv, env: DiagnosticsEnv,
@@ -161,12 +32,12 @@ export async function storeBug(
try { try {
const result = await database const result = await database
.prepare( .prepare(
`INSERT INTO bugs ( `INSERT INTO bugs (
id, received_at, occurred_at, install_id, app_version, platform, id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key, what_happened, expected, steps, contact, logs_r2_key,
device_json, breadcrumbs_json, status, schema_version device_json, status, schema_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
ON CONFLICT(id) DO NOTHING`, ON CONFLICT(id) DO NOTHING`,
) )
.bind( .bind(
payload.id, payload.id,
@@ -181,7 +52,6 @@ export async function storeBug(
payload.contact, payload.contact,
logsKey, logsKey,
JSON.stringify(payload.device), JSON.stringify(payload.device),
JSON.stringify(payload.breadcrumbs),
payload.schemaVersion, payload.schemaVersion,
) )
.run(); .run();
@@ -201,26 +71,22 @@ export async function storeBug(
export async function runRetention(env: DiagnosticsEnv): Promise<void> { export async function runRetention(env: DiagnosticsEnv): Promise<void> {
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650); const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
const cutoff = Date.now() - retentionDays * 86_400_000; const cutoff = Date.now() - retentionDays * 86_400_000;
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation. // Eight passes plus the backlog check stay well within D1's 50 queries per invocation.
for (let pass = 0; pass < 8; pass += 1) { for (let pass = 0; pass < 8; pass += 1) {
const hasFullBatch = await runRetentionPass(env, cutoff); const hasFullBatch = await runRetentionPass(env, cutoff);
if (!hasFullBatch) return; if (!hasFullBatch) return;
} }
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([ const bugs = await env.DB.prepare(
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind( "SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
cutoff, )
), .bind(cutoff)
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff), .first<{ count: number }>();
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
]);
console.warn( console.warn(
JSON.stringify({ JSON.stringify({
message: "diagnostics retention reached its per-run pass limit", message: "diagnostics retention reached its per-run pass limit",
cutoff, cutoff,
backlog: { backlog: {
eventBatches: events.results[0]?.count ?? 0, bugs: bugs?.count ?? 0,
crashes: crashes.results[0]?.count ?? 0,
bugs: bugs.results[0]?.count ?? 0,
}, },
}), }),
); );
@@ -228,28 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> { async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
const reportBatchSize = 900; const reportBatchSize = 900;
const eventBatchSize = 1_000; const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
const [crashes, bugs] = await Promise.all([
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
]);
const blobKeys = [...crashes, ...bugs] const blobKeys = bugs
.map((row) => row.blobKey) .map((row) => row.blobKey)
.filter((key): key is string => key !== null); .filter((key): key is string => key !== null);
for (let offset = 0; offset < blobKeys.length; offset += 1_000) { for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000)); await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
} }
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)]; if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes)); return bugs.length === reportBatchSize;
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
const [eventsResult] = await env.DB.batch(statements);
return (
eventsResult.meta.changes === eventBatchSize ||
crashes.length === reportBatchSize ||
bugs.length === reportBatchSize
);
} }
interface ExpiredBlobRow { interface ExpiredBlobRow {
@@ -259,8 +114,8 @@ interface ExpiredBlobRow {
async function expiredBlobRows( async function expiredBlobRows(
database: D1Database, database: D1Database,
table: "crashes" | "bugs", table: "bugs",
column: "stack_r2_key" | "logs_r2_key", column: "logs_r2_key",
cutoff: number, cutoff: number,
batchSize: number, batchSize: number,
): Promise<ExpiredBlobRow[]> { ): Promise<ExpiredBlobRow[]> {
@@ -277,25 +132,9 @@ async function expiredBlobRows(
return result.results; return result.results;
} }
function retentionStatement(
database: D1Database,
table: "event_batches" | "crashes" | "bugs",
cutoff: number,
batchSize: number,
): D1PreparedStatement {
return database
.prepare(
`DELETE FROM ${table}
WHERE rowid IN (
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
)`,
)
.bind(cutoff, batchSize);
}
function deleteRowsById( function deleteRowsById(
database: D1Database, database: D1Database,
table: "crashes" | "bugs", table: "bugs",
rows: ExpiredBlobRow[], rows: ExpiredBlobRow[],
): D1PreparedStatement { ): D1PreparedStatement {
return database return database
@@ -303,18 +142,6 @@ function deleteRowsById(
.bind(JSON.stringify(rows.map((row) => row.id))); .bind(JSON.stringify(rows.map((row) => row.id)));
} }
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
const topFrames = stackTrace
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 4)
.join("\n");
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> { async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
try { try {
await env.BLOBS.delete(key); await env.BLOBS.delete(key);

View File

@@ -1,11 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MAX_BREADCRUMBS_JSON_BYTES,
MAX_DEVICE_JSON_BYTES, MAX_DEVICE_JSON_BYTES,
MAX_LOG_BYTES, MAX_LOG_BYTES,
normalizeBug, normalizeBug,
normalizeCrash,
normalizeEvents,
readJsonObject, readJsonObject,
} from "../src/input"; } from "../src/input";
@@ -57,7 +54,7 @@ describe("readJsonObject", () => {
}); });
it("requires application/json with a UTF-8 charset", async () => { it("requires application/json with a UTF-8 charset", async () => {
const missing = new Request("https://example.test/v1/events", { const missing = new Request("https://example.test/v1/bugs", {
method: "POST", method: "POST",
body: "{}", body: "{}",
}); });
@@ -108,18 +105,6 @@ describe("readJsonObject", () => {
describe("normalizers", () => { describe("normalizers", () => {
it("preserves false booleans and rejects their string representation", () => { it("preserves false booleans and rejects their string representation", () => {
const crash = crashPayload(false);
const normalizedCrash = normalizeCrash(crash);
expect(normalizedCrash.ok).toBe(true);
if (normalizedCrash.ok) {
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
}
expect(normalizeCrash(crashPayload("false"))).toEqual({
ok: false,
status: 400,
error: "invalid_diagnostics_enabled",
});
const bug = bugPayload({ include_logs: false, logs: "discard me" }); const bug = bugPayload({ include_logs: false, logs: "discard me" });
const normalizedBug = normalizeBug(bug); const normalizedBug = normalizeBug(bug);
expect(normalizedBug.ok).toBe(true); expect(normalizedBug.ok).toBe(true);
@@ -133,20 +118,11 @@ describe("normalizers", () => {
}); });
}); });
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => { it("keeps logs and device JSON within valid byte budgets", () => {
const properties = Object.fromEntries(
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
);
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
name: `crumb-${index}`,
timestamp_millis: index,
properties,
}));
const result = normalizeBug( const result = normalizeBug(
bugPayload({ bugPayload({
include_logs: true, include_logs: true,
logs: "😀".repeat(60_000), logs: "😀".repeat(60_000),
breadcrumbs,
device: { device: {
device_name: "\u0000".repeat(200), device_name: "\u0000".repeat(200),
device_model: "\u0000".repeat(200), device_model: "\u0000".repeat(200),
@@ -159,54 +135,38 @@ describe("normalizers", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
const deviceJson = JSON.stringify(result.value.device); const deviceJson = JSON.stringify(result.value.device);
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES); expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
MAX_BREADCRUMBS_JSON_BYTES,
);
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES); expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
expect(JSON.parse(deviceJson)).toEqual(result.value.device); expect(JSON.parse(deviceJson)).toEqual(result.value.device);
}); });
it("requires stable report IDs and validates supplied IDs and schema versions", () => { it("requires stable report IDs and validates supplied IDs and schema versions", () => {
const result = normalizeEvents({ const missingId = normalizeBug(bugPayload({ id: undefined }));
events: [{ name: "opened", ts: 1, schema_version: 1 }], expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" });
});
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
const legacyInstall = normalizeEvents({
batch_id: ID,
install_id: "legacy-test-install",
events: [{ name: "opened", ts: 1 }],
});
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
const missingInstall = normalizeEvents({
batch_id: ID,
events: [{ name: "opened", ts: 1 }],
});
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
expect(
normalizeEvents({
batch_id: ID,
install_id: "bad\u0000install",
events: [{ name: "opened", ts: 1 }],
}),
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
expect( const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" }));
normalizeEvents({ expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
batch_id: "not-a-uuid",
events: [{ name: "opened", timestamp_millis: 1 }], const missingInstall = normalizeBug(bugPayload({ install_id: undefined }));
}), expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
expect( expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({
normalizeEvents({ ok: false,
batch_id: ID, status: 400,
install_id: INSTALL_ID, error: "invalid_install_id",
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }], });
}),
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" }); expect(normalizeBug(bugPayload({ id: "not-a-uuid" }))).toEqual({
ok: false,
status: 400,
error: "invalid_id",
});
expect(normalizeBug(bugPayload({ schema_version: 2 }))).toEqual({
ok: false,
status: 400,
error: "unsupported_schema_version",
});
}); });
}); });
@@ -215,7 +175,7 @@ function chunkedJsonRequest(
contentType = "application/json; charset=utf-8", contentType = "application/json; charset=utf-8",
contentLength?: string, contentLength?: string,
): Request { ): Request {
return new Request("https://example.test/v1/events", { return new Request("https://example.test/v1/bugs", {
method: "POST", method: "POST",
headers: { headers: {
"content-type": contentType, "content-type": contentType,
@@ -230,24 +190,8 @@ function chunkedJsonRequest(
}); });
} }
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
return {
id: ID,
install_id: INSTALL_ID,
app_version: "1.0",
platform: "test",
exception_type: "ExampleError",
exception_message: "message",
stack_trace: "stack",
occurred_at: 1,
diagnostics_enabled: diagnosticsEnabled,
schema_version: 1,
breadcrumbs: [],
};
}
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> { function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { const payload: Record<string, unknown> = {
id: ID, id: ID,
install_id: INSTALL_ID, install_id: INSTALL_ID,
app_version: "1.0", app_version: "1.0",
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
contact: "", contact: "",
logs: "", logs: "",
device: {}, device: {},
breadcrumbs: [],
schema_version: 1, schema_version: 1,
...overrides, ...overrides,
}; };
// An explicit `undefined` override omits the key entirely (simulating a missing field).
for (const key of Object.keys(overrides)) {
if (overrides[key] === undefined) delete payload[key];
}
return payload;
} }

View File

@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
import { createExecutionContext } from "cloudflare:test"; import { createExecutionContext } from "cloudflare:test";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import worker from "../src/index"; import worker from "../src/index";
import type { import type { NormalizedBugPayload } from "../src/input";
NormalizedBugPayload, import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
NormalizedCrashPayload,
NormalizedEventsPayload,
} from "../src/input";
import {
type DiagnosticsEnv,
runRetention,
storeBug,
storeCrash,
storeEvents,
} from "../src/storage";
const INSTALL_ID = "10000000-0000-4000-8000-000000000000"; const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
expect(unknown.status).toBe(404); expect(unknown.status).toBe(404);
const unauthorized = await exports.default.fetch( const unauthorized = await exports.default.fetch(
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"), jsonRequest("/v1/bugs", bugPayload(uuid(1), "logs"), "wrong-key"),
); );
expect(unauthorized.status).toBe(401); expect(unauthorized.status).toBe(401);
expect(await unauthorized.json()).toEqual({ error: "unauthorized" }); expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
@@ -44,7 +34,7 @@ describe("diagnostics Worker", () => {
); );
const preflight = await exports.default.fetch( const preflight = await exports.default.fetch(
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }), new Request("https://diagnostics.test/v1/bugs", { method: "OPTIONS" }),
); );
expect(preflight.status).toBe(204); expect(preflight.status).toBe(204);
expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
@@ -68,7 +58,7 @@ describe("diagnostics Worker", () => {
const context = createExecutionContext(); const context = createExecutionContext();
const response = await worker.fetch( const response = await worker.fetch(
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"), jsonRequest("/v1/bugs", bugPayload(uuid(3), "logs"), "wrong-key", "198.51.100.3"),
limitedEnv, limitedEnv,
context, context,
); );
@@ -80,7 +70,7 @@ describe("diagnostics Worker", () => {
}); });
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => { it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null)); const invalid = await exports.default.fetch(jsonRequest("/v1/bugs", null));
expect(invalid.status).toBe(400); expect(invalid.status).toBe(400);
expect(await invalid.json()).toEqual({ error: "invalid_body" }); expect(await invalid.json()).toEqual({ error: "invalid_body" });
@@ -92,6 +82,8 @@ describe("diagnostics Worker", () => {
}; };
const rejectingDatabase = { const rejectingDatabase = {
prepare: () => statement, prepare: () => statement,
batch: async () => Promise.reject(rejection),
withSession: () => ({ prepare: () => statement }),
} as unknown as D1Database; } as unknown as D1Database;
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase }; const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
@@ -106,7 +98,7 @@ describe("diagnostics Worker", () => {
const ingestContext = createExecutionContext(); const ingestContext = createExecutionContext();
const failedIngest = await worker.fetch( const failedIngest = await worker.fetch(
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"), jsonRequest("/v1/bugs", bugPayload(uuid(2), "logs"), env.INGEST_KEY, "198.51.100.2"),
rejectingEnv, rejectingEnv,
ingestContext, ingestContext,
); );
@@ -114,101 +106,6 @@ describe("diagnostics Worker", () => {
expect(await failedIngest.json()).toEqual({ error: "internal" }); expect(await failedIngest.json()).toEqual({ error: "internal" });
}); });
it("deduplicates event batches using the client batch ID", async () => {
const id = uuid(10);
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
expect(first.status).toBe(202);
expect(await first.json()).toMatchObject({
ok: true,
id,
stored: 1,
duplicate: false,
});
expect(second.status).toBe(202);
expect(await second.json()).toMatchObject({
ok: true,
id,
stored: 0,
duplicate: true,
});
const row = await env.DB.prepare(
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
)
.bind(id)
.first<{ eventCount: number; payloadJson: string }>();
expect(row?.eventCount).toBe(1);
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
{
name: "app_open",
timestampMillis: 1,
properties: { screen: "home" },
schemaVersion: 1,
},
]);
});
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
const points: AnalyticsEngineDataPoint[] = [];
const analytics = {
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
} as AnalyticsEngineDataset;
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
const payload: NormalizedEventsPayload = {
batchId: uuid(11),
installId: INSTALL_ID,
appVersion: "1.0",
platform: "test",
events: [
{
name: "indexed",
timestampMillis: 1,
properties: {},
schemaVersion: 1,
},
],
};
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
expect(points).toHaveLength(1);
});
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
const id = uuid(20);
const first = await exports.default.fetch(
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
);
const second = await exports.default.fetch(
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
);
expect(first.status).toBe(202);
const firstBody = await first.json<{ fingerprint: string }>();
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
expect(second.status).toBe(202);
const secondBody = await second.json<{ fingerprint: string }>();
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
const row = await env.DB.prepare(
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
fingerprint
FROM crashes WHERE id = ?`,
)
.bind(id)
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
expect(firstBody.fingerprint).toBe(row?.fingerprint);
expect(secondBody.fingerprint).toBe(row?.fingerprint);
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
});
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => { it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
const id = uuid(30); const id = uuid(30);
const payload = bugPayload(id, "first logs"); const payload = bugPayload(id, "first logs");
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
const row = await env.DB.prepare( const row = await env.DB.prepare(
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey, `SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson device_json AS deviceJson
FROM bugs WHERE id = ?`, FROM bugs WHERE id = ?`,
) )
.bind(id) .bind(id)
@@ -232,7 +129,6 @@ describe("diagnostics Worker", () => {
occurredAt: number; occurredAt: number;
logsKey: string; logsKey: string;
deviceJson: string; deviceJson: string;
breadcrumbsJson: string;
}>(); }>();
expect(row?.occurredAt).toBe(3); expect(row?.occurredAt).toBe(3);
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({ expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
@@ -242,9 +138,6 @@ describe("diagnostics Worker", () => {
network: "offline", network: "offline",
batteryLevel: "90%", batteryLevel: "90%",
}); });
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
{ name: "opened", timestampMillis: 2, properties: {} },
]);
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs"); expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` }); const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
@@ -252,9 +145,7 @@ describe("diagnostics Worker", () => {
}); });
it("acknowledges known report IDs without touching an unavailable blob store", async () => { it("acknowledges known report IDs without touching an unavailable blob store", async () => {
const crash = normalizedCrash(uuid(31), "accepted stack");
const bug = normalizedBug(uuid(32), "accepted logs"); const bug = normalizedBug(uuid(32), "accepted logs");
const firstCrash = await storeCrash(crash, env);
await storeBug(bug, env); await storeBug(bug, env);
let blobWrites = 0; let blobWrites = 0;
const unavailableBlobs = { const unavailableBlobs = {
@@ -265,14 +156,6 @@ describe("diagnostics Worker", () => {
} as unknown as R2Bucket; } as unknown as R2Bucket;
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs }; const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
await expect(
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
).resolves.toEqual({
id: crash.id,
duplicate: true,
stored: 0,
fingerprint: firstCrash.fingerprint,
});
await expect( await expect(
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv), storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 }); ).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
@@ -286,88 +169,56 @@ describe("diagnostics Worker", () => {
async () => Promise.reject(rejection), async () => Promise.reject(rejection),
); );
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase }; const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
const crashId = uuid(33);
const bugId = uuid(34); const bugId = uuid(34);
const crashResponse = await worker.fetch(
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
rejectingEnv,
createExecutionContext(),
);
const bugResponse = await worker.fetch( const bugResponse = await worker.fetch(
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"), jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
rejectingEnv, rejectingEnv,
createExecutionContext(), createExecutionContext(),
); );
expect(crashResponse.status).toBe(500);
expect(await crashResponse.json()).toEqual({ error: "internal" });
expect(bugResponse.status).toBe(500); expect(bugResponse.status).toBe(500);
expect(await bugResponse.json()).toEqual({ error: "internal" }); expect(await bugResponse.json()).toEqual({ error: "internal" });
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]); expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
}); });
it("removes expired rows and their exact R2 objects while preserving current data", async () => { it("removes expired rows and their exact R2 objects while preserving current data", async () => {
const oldEventId = uuid(40);
const oldCrashId = uuid(41);
const oldBugId = uuid(42); const oldBugId = uuid(42);
const currentEventId = uuid(43); const currentBugId = uuid(43);
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`; const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
const oldReceivedAt = Date.now() - 100 * 86_400_000; const oldReceivedAt = Date.now() - 100 * 86_400_000;
await Promise.all([ await env.BLOBS.put(oldBugKey, "expired logs");
env.BLOBS.put(oldCrashKey, "expired crash"),
env.BLOBS.put(oldBugKey, "expired logs"),
]);
await env.DB.batch([ await env.DB.batch([
env.DB.prepare(
`INSERT INTO event_batches
(id, received_at, install_id, app_version, platform, event_count, payload_json)
VALUES (?, ?, ?, '', '', 1, '[]')`,
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
env.DB.prepare(
`INSERT INTO event_batches
(id, received_at, install_id, app_version, platform, event_count, payload_json)
VALUES (?, ?, ?, '', '', 1, '[]')`,
).bind(currentEventId, Date.now(), INSTALL_ID),
env.DB.prepare(
`INSERT INTO crashes
(id, received_at, occurred_at, install_id, app_version, platform,
exception_type, exception_message, fingerprint, diagnostics_enabled,
stack_r2_key, breadcrumbs_json, schema_version)
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
env.DB.prepare( env.DB.prepare(
`INSERT INTO bugs `INSERT INTO bugs
(id, received_at, occurred_at, install_id, app_version, platform, (id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key, what_happened, expected, steps, contact, logs_r2_key,
device_json, breadcrumbs_json, status, schema_version) device_json, status, schema_version)
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`, VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`,
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey), ).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
env.DB.prepare(
`INSERT INTO bugs
(id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key,
device_json, status, schema_version)
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', 'open', 1)`,
).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID),
]); ]);
await runRetention(env); await runRetention(env);
for (const [table, id] of [ expect(
["event_batches", oldEventId], await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(),
["crashes", oldCrashId], ).toBeNull();
["bugs", oldBugId],
] as const) {
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
expect(row).toBeNull();
}
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
expect(await env.BLOBS.head(oldBugKey)).toBeNull(); expect(await env.BLOBS.head(oldBugKey)).toBeNull();
expect( expect(
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(), await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(currentBugId).first(),
).not.toBeNull(); ).not.toBeNull();
}); });
it("bounds a full retention run below the D1 per-invocation query limit", async () => { it("bounds a full retention run below the D1 per-invocation query limit", async () => {
let queryCount = 0; let queryCount = 0;
let batchCalls = 0;
const blobDeleteBatchSizes: number[] = []; const blobDeleteBatchSizes: number[] = [];
const rows = Array.from({ length: 900 }, (_, index) => ({ const rows = Array.from({ length: 900 }, (_, index) => ({
id: `expired-${index}`, id: `expired-${index}`,
@@ -381,17 +232,17 @@ describe("diagnostics Worker", () => {
queryCount += 1; queryCount += 1;
return d1Result(rows, 0); return d1Result(rows, 0);
}, },
run: async () => {
queryCount += 1;
return d1Result([], rows.length);
},
first: async () => {
queryCount += 1;
return { count: rows.length };
},
}; };
return statement; return statement;
}, },
batch: async (statements: D1PreparedStatement[]) => {
batchCalls += 1;
queryCount += statements.length;
if (batchCalls === 9) {
return statements.map(() => d1Result([{ count: 1 }], 0));
}
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
},
} as unknown as D1Database; } as unknown as D1Database;
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const blobs = { const blobs = {
@@ -406,9 +257,10 @@ describe("diagnostics Worker", () => {
warning.mockRestore(); warning.mockRestore();
} }
expect(queryCount).toBe(43); // Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT.
expect(blobDeleteBatchSizes).toHaveLength(16); expect(queryCount).toBe(17);
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000); expect(blobDeleteBatchSizes).toHaveLength(8);
expect(Math.max(...blobDeleteBatchSizes)).toBe(900);
}); });
it("converges an expired report backlog across bounded retention runs", async () => { it("converges an expired report backlog across bounded retention runs", async () => {
@@ -424,13 +276,13 @@ describe("diagnostics Worker", () => {
CROSS JOIN digits AS ones CROSS JOIN digits AS ones
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201 WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
) )
INSERT INTO crashes ( INSERT INTO bugs (
id, received_at, occurred_at, install_id, app_version, platform, id, received_at, occurred_at, install_id, app_version, platform,
exception_type, exception_message, fingerprint, diagnostics_enabled, what_happened, expected, steps, contact, logs_r2_key,
stack_r2_key, breadcrumbs_json, schema_version device_json, status, schema_version
) )
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '', SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1 'failed', 'worked', '', '', NULL, '{}', 'open', 1
FROM sequence`, FROM sequence`,
) )
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID) .bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
@@ -440,13 +292,13 @@ describe("diagnostics Worker", () => {
try { try {
await runRetention(env); await runRetention(env);
const afterFirstRun = await env.DB.prepare( const afterFirstRun = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'", "SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
).first<{ count: number }>(); ).first<{ count: number }>();
expect(afterFirstRun?.count).toBe(1); expect(afterFirstRun?.count).toBe(1);
await runRetention(env); await runRetention(env);
const afterSecondRun = await env.DB.prepare( const afterSecondRun = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'", "SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
).first<{ count: number }>(); ).first<{ count: number }>();
expect(afterSecondRun?.count).toBe(0); expect(afterSecondRun?.count).toBe(0);
} finally { } finally {
@@ -482,39 +334,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
}); });
} }
function eventPayload(batchId: string): Record<string, unknown> {
return {
batchId,
installId: INSTALL_ID,
appVersion: "1.0",
platform: "test",
events: [
{
name: "app_open",
timestampMillis: 1,
properties: { screen: "home" },
schemaVersion: 1,
},
],
};
}
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
return {
id,
installId: INSTALL_ID,
appVersion: "1.0",
platform: "test",
exceptionType: "TestError",
exceptionMessage: "failed",
stackTrace,
timestampMillis: 2,
diagnosticsEnabledAtCapture: true,
breadcrumbs: [],
schemaVersion: 1,
};
}
function bugPayload(id: string, logs: string): Record<string, unknown> { function bugPayload(id: string, logs: string): Record<string, unknown> {
return { return {
id, id,
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
network: "offline", network: "offline",
batteryLevel: "90%", batteryLevel: "90%",
}, },
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
schemaVersion: 1,
};
}
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
return {
id,
installId: INSTALL_ID,
appVersion: "1.0",
platform: "test",
exceptionType: "TestError",
exceptionMessage: "failed",
stackTrace,
occurredAt: 2,
diagnosticsEnabledAtCapture: true,
breadcrumbs: [],
schemaVersion: 1, schemaVersion: 1,
}; };
} }
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
network: "offline", network: "offline",
batteryLevel: "90%", batteryLevel: "90%",
}, },
breadcrumbs: [],
schemaVersion: 1, schemaVersion: 1,
}; };
} }

View File

@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: e2953336d40e96c4b5125a5de01f7cac) // Generated by Wrangler by running `wrangler types` (hash: 9c27cfd9219ba0d4efc153db78cad4c3)
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat // Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
interface __BaseEnv_Env { interface __BaseEnv_Env {
BLOBS: R2Bucket; BLOBS: R2Bucket;
@@ -7,7 +7,6 @@ interface __BaseEnv_Env {
INSTALL_RATE_LIMITER: RateLimit; INSTALL_RATE_LIMITER: RateLimit;
SOURCE_RATE_LIMITER: RateLimit; SOURCE_RATE_LIMITER: RateLimit;
MAX_BODY_BYTES: "262144"; MAX_BODY_BYTES: "262144";
MAX_EVENTS_PER_BATCH: "50";
RETENTION_DAYS: "90"; RETENTION_DAYS: "90";
} }
declare namespace Cloudflare { declare namespace Cloudflare {
@@ -21,7 +20,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
}; };
declare namespace NodeJS { declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "MAX_EVENTS_PER_BATCH" | "RETENTION_DAYS">> {} interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "RETENTION_DAYS">> {}
} }
// Begin runtime types // Begin runtime types

View File

@@ -47,13 +47,8 @@
}, },
}, },
], ],
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
// "analytics_engine_datasets": [
// { "binding": "AE", "dataset": "vnidrop_events" },
// ],
"vars": { "vars": {
"MAX_BODY_BYTES": "262144", "MAX_BODY_BYTES": "262144",
"MAX_EVENTS_PER_BATCH": "50",
"RETENTION_DAYS": "90", "RETENTION_DAYS": "90",
}, },
} }

View File

@@ -14,6 +14,7 @@ import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.jvm.tasks.Jar import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
abstract class VerifyHostCargoTaskSelection : DefaultTask() { abstract class VerifyHostCargoTaskSelection : DefaultTask() {
@get:Input @get:Input
@@ -49,7 +50,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.orElse(Variant.Debug) .orElse(Variant.Debug)
// Compile-time switches (gradle.properties or -P…). // Compile-time switches (gradle.properties or -P…).
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack. // included=false: user-initiated bug reports use a NoOp transport (never sent).
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
val diagnosticsIncluded: Boolean = val diagnosticsIncluded: Boolean =
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
@@ -110,6 +111,55 @@ val generateDiagnosticsBuildConfig by tasks.registering {
} }
} }
// App-wide public constants (privacy policy URL, …) from the shared app.properties,
// so Apple and KMP read one source of truth instead of hardcoding values.
val appProperties = Properties().apply {
rootProject.file("app.properties").inputStream().use(::load)
}
val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty()
check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" }
val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin")
val generateAppConfig by tasks.registering {
group = "build"
description = "Generates AppConfig from the shared app.properties"
val outputDir = appConfigDir
val privacy = privacyPolicyUrl
inputs.property("PRIVACY_POLICY_URL", privacy)
outputs.dir(outputDir)
doLast {
val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app")
packageDir.mkdirs()
fun esc(value: String): String = buildString {
for (ch in value) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
'$' -> append("\\$")
else -> append(ch)
}
}
}
packageDir.resolve("AppConfig.kt").writeText(
"""
|package com.vnidrop.app
|
|/**
| * Generated by shared/build.gradle.kts from the shared app.properties.
| * Single source of truth for app-wide public constants (also used by Apple).
| */
|object AppConfig {
| const val PRIVACY_POLICY_URL: String = "${esc(privacy)}"
|}
|
""".trimMargin(),
)
}
}
kotlin { kotlin {
androidTarget { androidTarget {
compilerOptions { compilerOptions {
@@ -122,6 +172,7 @@ kotlin {
sourceSets { sourceSets {
commonMain { commonMain {
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig)) kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig))
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

@@ -2,7 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<application>
<service
android:name="com.vnidrop.app.background.BackgroundSharingService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest> </manifest>

View File

@@ -8,6 +8,7 @@ import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.background.AndroidBackgroundSharingController
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
@@ -18,7 +19,8 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
val context = activity.applicationContext val context = activity.applicationContext
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
val notificationService = rememberAndroidLocalNotificationService(activity) val notificationService = rememberAndroidLocalNotificationService(activity)
return remember(context, fileSystemService, notificationService) { val backgroundSharingController = remember(context) { AndroidBackgroundSharingController(context) }
return remember(context, fileSystemService, notificationService, backgroundSharingController) {
AppDependencies( AppDependencies(
environment = PlatformEnvironment( environment = PlatformEnvironment(
name = "Android ${Build.VERSION.SDK_INT}", name = "Android ${Build.VERSION.SDK_INT}",
@@ -30,6 +32,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
deviceInfoProvider = AndroidDeviceInfoProvider(context), deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = notificationService, localNotificationService = notificationService,
backgroundSharingController = backgroundSharingController,
externalInvitations = externalInvitations, externalInvitations = externalInvitations,
) )
} }

View File

@@ -0,0 +1,18 @@
package com.vnidrop.app.background
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
class AndroidBackgroundSharingController(
private val context: Context,
) : BackgroundSharingController {
override fun setSharingActive(active: Boolean) {
val intent = Intent(context, BackgroundSharingService::class.java)
if (active) {
ContextCompat.startForegroundService(context, intent)
} else {
context.stopService(intent)
}
}
}

View File

@@ -0,0 +1,69 @@
package com.vnidrop.app.background
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.notifications_background_sharing_body
import vnidrop.shared.generated.resources.notifications_background_sharing_channel
import vnidrop.shared.generated.resources.notifications_background_sharing_title
class BackgroundSharingService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ensureNotificationChannel()
startForeground(NotificationId, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
private fun ensureNotificationChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
ChannelId,
localizedString(Res.string.notifications_background_sharing_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun createNotification(): Notification {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val contentIntent = launchIntent?.let {
PendingIntent.getActivity(
this,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
return Notification.Builder(this, ChannelId)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle(localizedString(Res.string.notifications_background_sharing_title))
.setContentText(localizedString(Res.string.notifications_background_sharing_body))
.setOngoing(true)
.setContentIntent(contentIntent)
.build()
}
private fun localizedString(resource: org.jetbrains.compose.resources.StringResource): String =
runBlocking { getString(resource) }
private companion object {
const val ChannelId = "vnidrop-background-sharing"
const val NotificationId = 3_427
}
}

View File

@@ -1,72 +0,0 @@
package com.vnidrop.app.diagnostics
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
AndroidPendingCrashStore(appDataDir)
private class AndroidPendingCrashStore(
appDataDir: String,
) : PendingCrashStore {
private val directory = File(appDataDir, "diagnostics/crashes")
@Synchronized
override fun write(report: CrashReport) {
if (!isValidDiagnosticId(report.id)) return
directory.mkdirs()
val target = File(directory, "${report.id}.crash")
val temporary = File(directory, ".${report.id}.tmp")
val payload = CrashReportCodec.encode(report)
temporary.writeText(payload, StandardCharsets.UTF_8)
if (!temporary.renameTo(target)) {
target.writeText(payload, StandardCharsets.UTF_8)
temporary.delete()
}
}
@Synchronized
override fun list(): List<CrashReport> {
if (!directory.isDirectory) return emptyList()
return directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.mapNotNull { file ->
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
}
}
@Synchronized
override fun delete(id: String) {
if (!isValidDiagnosticId(id)) return
File(directory, "$id.crash").delete()
}
@Synchronized
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
require(maxCount > 0) { "maxCount must be positive" }
if (!directory.isDirectory) return
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
.orEmpty()
.forEach(File::delete)
val reports = directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.mapNotNull { file ->
val report = runCatching {
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
}.getOrNull()
if (report == null) {
file.delete()
null
} else {
file to report
}
}
.sortedByDescending { (_, report) -> report.timestampMillis }
reports.forEachIndexed { index, (file, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
}
}
}

View File

@@ -1,9 +0,0 @@
package com.vnidrop.app.diagnostics
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching { onCrash(throwable) }
previous?.uncaughtException(thread, throwable)
}
}

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Auf NFC-Tag schreiben</string> <string name="button_write_nfc">Auf NFC-Tag schreiben</string>
<string name="device_model_title">Gerätemodell</string> <string name="device_model_title">Gerätemodell</string>
<string name="device_name_title">Gerätename</string> <string name="device_name_title">Gerätename</string>
<string name="diagnostics_description">Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.</string>
<string name="diagnostics_disabled_message">Die Freigabe von Diagnosedaten ist deaktiviert.</string>
<string name="diagnostics_enabled_message">Die Freigabe von Diagnosedaten ist aktiviert.</string>
<string name="diagnostics_title">Diagnosedaten teilen</string>
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string> <string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string> <string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string> <string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Einstellungen</string> <string name="nav_settings">Einstellungen</string>
<string name="network_title">Netzwerk</string> <string name="network_title">Netzwerk</string>
<string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string> <string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string>
<string name="notifications_background_sharing_body">VniDrop kann Ihre Dateien im Hintergrund freigeben.</string>
<string name="notifications_background_sharing_channel">Aktive Übertragungen</string>
<string name="notifications_background_sharing_title">Freigabe im Hintergrund</string>
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string> <string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
<string name="notifications_local_title">Mitteilungen erlauben</string> <string name="notifications_local_title">Mitteilungen erlauben</string>
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string> <string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escribir en etiqueta NFC</string> <string name="button_write_nfc">Escribir en etiqueta NFC</string>
<string name="device_model_title">Modelo del dispositivo</string> <string name="device_model_title">Modelo del dispositivo</string>
<string name="device_name_title">Nombre del dispositivo</string> <string name="device_name_title">Nombre del dispositivo</string>
<string name="diagnostics_description">Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.</string>
<string name="diagnostics_disabled_message">El uso compartido de diagnósticos está desactivado.</string>
<string name="diagnostics_enabled_message">El uso compartido de diagnósticos está activado.</string>
<string name="diagnostics_title">Compartir diagnósticos</string>
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string> <string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string> <string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string> <string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Ajustes</string> <string name="nav_settings">Ajustes</string>
<string name="network_title">Red</string> <string name="network_title">Red</string>
<string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string> <string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string>
<string name="notifications_background_sharing_body">VniDrop está listo para compartir sus archivos en segundo plano.</string>
<string name="notifications_background_sharing_channel">Transferencias activas</string>
<string name="notifications_background_sharing_title">Compartiendo en segundo plano</string>
<string name="notifications_enabled_message">Notificaciones activadas.</string> <string name="notifications_enabled_message">Notificaciones activadas.</string>
<string name="notifications_local_title">Permitir notificaciones</string> <string name="notifications_local_title">Permitir notificaciones</string>
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string> <string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Écrire sur un tag NFC</string> <string name="button_write_nfc">Écrire sur un tag NFC</string>
<string name="device_model_title">Modèle de lappareil</string> <string name="device_model_title">Modèle de lappareil</string>
<string name="device_name_title">Nom de lappareil</string> <string name="device_name_title">Nom de lappareil</string>
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements dutilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.</string>
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
<string name="diagnostics_title">Partager les diagnostics</string>
<string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string> <string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string>
<string name="error_device_info">Impossible de charger les informations de lappareil.</string> <string name="error_device_info">Impossible de charger les informations de lappareil.</string>
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string> <string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Réglages</string> <string name="nav_settings">Réglages</string>
<string name="network_title">Réseau</string> <string name="network_title">Réseau</string>
<string name="notifications_description">Soyez averti de lactivité des transferts lorsque VniDrop est en arrière-plan.</string> <string name="notifications_description">Soyez averti de lactivité des transferts lorsque VniDrop est en arrière-plan.</string>
<string name="notifications_background_sharing_body">VniDrop est prêt à partager vos fichiers en arrière-plan.</string>
<string name="notifications_background_sharing_channel">Transferts actifs</string>
<string name="notifications_background_sharing_title">Partage en arrière-plan</string>
<string name="notifications_enabled_message">Notifications activées.</string> <string name="notifications_enabled_message">Notifications activées.</string>
<string name="notifications_local_title">Autoriser les notifications</string> <string name="notifications_local_title">Autoriser les notifications</string>
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string> <string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Scrivi su tag NFC</string> <string name="button_write_nfc">Scrivi su tag NFC</string>
<string name="device_model_title">Modello del dispositivo</string> <string name="device_model_title">Modello del dispositivo</string>
<string name="device_name_title">Nome del dispositivo</string> <string name="device_name_title">Nome del dispositivo</string>
<string name="diagnostics_description">Invia report di arresto anomalo ed eventi duso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</string>
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
<string name="diagnostics_title">Condividi dati diagnostici</string>
<string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string> <string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string>
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string> <string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string> <string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Impostazioni</string> <string name="nav_settings">Impostazioni</string>
<string name="network_title">Rete</string> <string name="network_title">Rete</string>
<string name="notifications_description">Ricevi avvisi sullattività dei trasferimenti quando VniDrop è in background.</string> <string name="notifications_description">Ricevi avvisi sullattività dei trasferimenti quando VniDrop è in background.</string>
<string name="notifications_background_sharing_body">VniDrop è pronto a condividere i tuoi file in background.</string>
<string name="notifications_background_sharing_channel">Trasferimenti attivi</string>
<string name="notifications_background_sharing_title">Condivisione in background</string>
<string name="notifications_enabled_message">Notifiche attivate.</string> <string name="notifications_enabled_message">Notifiche attivate.</string>
<string name="notifications_local_title">Consenti le notifiche</string> <string name="notifications_local_title">Consenti le notifiche</string>
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string> <string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Naar NFC-tag schrijven</string> <string name="button_write_nfc">Naar NFC-tag schrijven</string>
<string name="device_model_title">Apparaatmodel</string> <string name="device_model_title">Apparaatmodel</string>
<string name="device_name_title">Apparaatnaam</string> <string name="device_name_title">Apparaatnaam</string>
<string name="diagnostics_description">Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.</string>
<string name="diagnostics_disabled_message">Het delen van diagnostische gegevens is uitgeschakeld.</string>
<string name="diagnostics_enabled_message">Het delen van diagnostische gegevens is ingeschakeld.</string>
<string name="diagnostics_title">Diagnostische gegevens delen</string>
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string> <string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string> <string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string> <string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Instellingen</string> <string name="nav_settings">Instellingen</string>
<string name="network_title">Netwerk</string> <string name="network_title">Netwerk</string>
<string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string> <string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string>
<string name="notifications_background_sharing_body">VniDrop is klaar om uw bestanden op de achtergrond te delen.</string>
<string name="notifications_background_sharing_channel">Actieve overdrachten</string>
<string name="notifications_background_sharing_title">Delen op de achtergrond</string>
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string> <string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
<string name="notifications_local_title">Meldingen toestaan</string> <string name="notifications_local_title">Meldingen toestaan</string>
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string> <string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Zapisz na tagu NFC</string> <string name="button_write_nfc">Zapisz na tagu NFC</string>
<string name="device_model_title">Model urządzenia</string> <string name="device_model_title">Model urządzenia</string>
<string name="device_name_title">Nazwa urządzenia</string> <string name="device_name_title">Nazwa urządzenia</string>
<string name="diagnostics_description">Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.</string>
<string name="diagnostics_disabled_message">Udostępnianie diagnostyki jest wyłączone.</string>
<string name="diagnostics_enabled_message">Udostępnianie diagnostyki jest włączone.</string>
<string name="diagnostics_title">Udostępniaj diagnostykę</string>
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string> <string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string> <string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string> <string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Ustawienia</string> <string name="nav_settings">Ustawienia</string>
<string name="network_title">Sieć</string> <string name="network_title">Sieć</string>
<string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string> <string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string>
<string name="notifications_background_sharing_body">VniDrop jest gotowy do udostępniania plików w tle.</string>
<string name="notifications_background_sharing_channel">Aktywne transfery</string>
<string name="notifications_background_sharing_title">Udostępnianie w tle</string>
<string name="notifications_enabled_message">Powiadomienia włączone.</string> <string name="notifications_enabled_message">Powiadomienia włączone.</string>
<string name="notifications_local_title">Zezwól na powiadomienia</string> <string name="notifications_local_title">Zezwól na powiadomienia</string>
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string> <string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escrever em etiqueta NFC</string> <string name="button_write_nfc">Escrever em etiqueta NFC</string>
<string name="device_model_title">Modelo do dispositivo</string> <string name="device_model_title">Modelo do dispositivo</string>
<string name="device_name_title">Nome do dispositivo</string> <string name="device_name_title">Nome do dispositivo</string>
<string name="diagnostics_description">Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.</string>
<string name="diagnostics_disabled_message">A partilha de diagnósticos está desativada.</string>
<string name="diagnostics_enabled_message">A partilha de diagnósticos está ativada.</string>
<string name="diagnostics_title">Partilhar diagnósticos</string>
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string> <string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string> <string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string> <string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Definições</string> <string name="nav_settings">Definições</string>
<string name="network_title">Rede</string> <string name="network_title">Rede</string>
<string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string> <string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string>
<string name="notifications_background_sharing_body">O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.</string>
<string name="notifications_background_sharing_channel">Transferências ativas</string>
<string name="notifications_background_sharing_title">Partilha em segundo plano</string>
<string name="notifications_enabled_message">Notificações ativadas.</string> <string name="notifications_enabled_message">Notificações ativadas.</string>
<string name="notifications_local_title">Permitir notificações</string> <string name="notifications_local_title">Permitir notificações</string>
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string> <string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Записать на NFC-метку</string> <string name="button_write_nfc">Записать на NFC-метку</string>
<string name="device_model_title">Модель устройства</string> <string name="device_model_title">Модель устройства</string>
<string name="device_name_title">Имя устройства</string> <string name="device_name_title">Имя устройства</string>
<string name="diagnostics_description">Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются.</string>
<string name="diagnostics_disabled_message">Передача диагностики отключена.</string>
<string name="diagnostics_enabled_message">Передача диагностики включена.</string>
<string name="diagnostics_title">Делиться диагностикой</string>
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string> <string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string> <string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string> <string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Настройки</string> <string name="nav_settings">Настройки</string>
<string name="network_title">Сеть</string> <string name="network_title">Сеть</string>
<string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string> <string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string>
<string name="notifications_background_sharing_body">VniDrop готов отправлять ваши файлы в фоновом режиме.</string>
<string name="notifications_background_sharing_channel">Активные передачи</string>
<string name="notifications_background_sharing_title">Отправка в фоне</string>
<string name="notifications_enabled_message">Уведомления включены.</string> <string name="notifications_enabled_message">Уведомления включены.</string>
<string name="notifications_local_title">Разрешить уведомления</string> <string name="notifications_local_title">Разрешить уведомления</string>
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string> <string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Write to NFC tag</string> <string name="button_write_nfc">Write to NFC tag</string>
<string name="device_model_title">Device model</string> <string name="device_model_title">Device model</string>
<string name="device_name_title">Device name</string> <string name="device_name_title">Device name</string>
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.</string>
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
<string name="diagnostics_title">Share diagnostics</string>
<string name="error_camera">Camera access is required to scan a QR code.</string> <string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_device_info">Could not load device information.</string> <string name="error_device_info">Could not load device information.</string>
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string> <string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
@@ -120,6 +116,9 @@
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
<string name="network_title">Network</string> <string name="network_title">Network</string>
<string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string> <string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string>
<string name="notifications_background_sharing_body">VniDrop is ready to share your files in the background.</string>
<string name="notifications_background_sharing_channel">Active transfers</string>
<string name="notifications_background_sharing_title">Sharing in the background</string>
<string name="notifications_enabled_message">Notifications enabled.</string> <string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_local_title">Allow notifications</string> <string name="notifications_local_title">Allow notifications</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string> <string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>

View File

@@ -80,7 +80,6 @@ fun App(
graph.coreRepository, graph.coreRepository,
graph.preferencesRepository, graph.preferencesRepository,
graph.messages, graph.messages,
graph.diagnostics,
) )
} }
val sendViewModel = viewModel { val sendViewModel = viewModel {
@@ -105,7 +104,6 @@ fun App(
dependencies.localNotificationService, dependencies.localNotificationService,
graph.messages, graph.messages,
graph.diagnostics.bugReports, graph.diagnostics.bugReports,
graph.diagnostics,
) )
} }
val appState by appViewModel.state.collectAsStateWithLifecycle() val appState by appViewModel.state.collectAsStateWithLifecycle()

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.background.BackgroundSharingCoordinator
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.diagnostics.createDiagnosticsTransport import com.vnidrop.app.diagnostics.createDiagnosticsTransport
import com.vnidrop.app.feature.approvals.ApprovalCoordinator import com.vnidrop.app.feature.approvals.ApprovalCoordinator
@@ -19,9 +20,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class AppGraph( class AppGraph(
val dependencies: AppDependencies, val dependencies: AppDependencies,
@@ -40,11 +38,9 @@ class AppGraph(
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(), receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = false,
), ),
) )
val diagnostics = DiagnosticsCoordinator.create( val diagnostics = DiagnosticsCoordinator.create(
appDataDir = dependencies.environment.defaultCoreDataDir,
appVersion = dependencies.environment.appVersion, appVersion = dependencies.environment.appVersion,
platform = dependencies.environment.name, platform = dependencies.environment.name,
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
@@ -71,19 +67,19 @@ class AppGraph(
messages = messages, messages = messages,
scope = applicationScope, scope = applicationScope,
) )
private val backgroundSharingCoordinator = BackgroundSharingCoordinator(
repository = coreRepository,
controller = dependencies.backgroundSharingController,
scope = applicationScope,
)
init { init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir) AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
diagnostics.start() diagnostics.start()
applicationScope.launch {
visibility.isForeground
.drop(1)
.filter { isForeground -> !isForeground }
.collect { diagnostics.telemetry.flush() }
}
} }
fun close() { fun close() {
backgroundSharingCoordinator.stop()
coreRepository.shutdown() coreRepository.shutdown()
applicationScope.cancel() applicationScope.cancel()
} }

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.background.BackgroundSharingController
enum class UiPlatform { enum class UiPlatform {
Android, Android,
@@ -39,5 +40,6 @@ data class AppDependencies(
val deviceInfoProvider: DeviceInfoProvider, val deviceInfoProvider: DeviceInfoProvider,
val fileSystemService: FileSystemService, val fileSystemService: FileSystemService,
val localNotificationService: LocalNotificationService, val localNotificationService: LocalNotificationService,
val backgroundSharingController: BackgroundSharingController,
val externalInvitations: ExternalInvitationController, val externalInvitations: ExternalInvitationController,
) )

View File

@@ -0,0 +1,38 @@
package com.vnidrop.app.background
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/** Keeps the platform process eligible to serve an outgoing share while its UI is backgrounded. */
fun interface BackgroundSharingController {
fun setSharingActive(active: Boolean)
}
class BackgroundSharingCoordinator(
repository: CoreGateway,
private val controller: BackgroundSharingController,
scope: CoroutineScope,
) {
init {
scope.launch {
repository.state
.map(::requiresBackgroundSharing)
.distinctUntilChanged()
.collect(controller::setSharingActive)
}
}
fun stop() = controller.setSharingActive(false)
}
internal fun requiresBackgroundSharing(state: CoreState): Boolean =
state.isInitialized && state.transfers.any { transfer ->
transfer.direction == TransferDirection.Send &&
transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
}

View File

@@ -1,47 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
* Always in-memory only; never auto-uploaded without policy + transport.
*
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
* to blocking a dying process on a lock.
*/
@OptIn(ExperimentalAtomicApi::class)
class BreadcrumbBuffer(
private val capacity: Int = DefaultCapacity,
) {
init {
require(capacity > 0) { "capacity must be positive" }
}
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val crumb = Breadcrumb(
name = sanitizedName,
timestampMillis = timestampMillis,
properties = sanitizeDiagnosticProperties(properties),
)
while (true) {
val current = items.load()
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
}
}
fun snapshot(): List<Breadcrumb> = items.load()
fun clear() {
items.store(emptyList())
}
companion object {
const val DefaultCapacity = 40
}
}

View File

@@ -21,7 +21,6 @@ data class BugReportDraft(
class BugReportService( class BugReportService(
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport, private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val appVersion: String, private val appVersion: String,
private val platform: String, private val platform: String,
private val logReader: () -> String = { private val logReader: () -> String = {
@@ -53,7 +52,6 @@ class BugReportService(
network = deviceInfo?.network?.takeUtf8Bytes(96), network = deviceInfo?.network?.takeUtf8Bytes(96),
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64), batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
), ),
breadcrumbs = breadcrumbs.snapshot(),
) )
} }

View File

@@ -1,158 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Captures uncaught exceptions to disk, then uploads on a later launch when
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
*/
@OptIn(ExperimentalAtomicApi::class)
class CrashReporter(
private val store: PendingCrashStore,
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val appVersion: String,
private val platform: String,
private val scope: CoroutineScope,
) {
private val installed = AtomicBoolean(false)
private val observingPreferences = AtomicBoolean(false)
private val capturePolicy = AtomicReference(CrashCapturePolicy())
fun startObservingPreferences() {
if (!observingPreferences.compareAndSet(false, true)) return
scope.launch {
preferencesRepository.preferences.collect { prefs ->
capturePolicy.store(
CrashCapturePolicy(
installId = prefs.diagnosticsInstallId,
diagnosticsEnabled = prefs.diagnosticsEnabled,
),
)
if (!prefs.diagnosticsEnabled) {
runCatching(::deleteAllPending)
}
}
}
}
fun installUnhandledExceptionHandler() {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (!installed.compareAndSet(false, true)) return
installPlatformCrashHook { throwable ->
capture(throwable)
}
}
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
val policy = capturePolicy.load()
val report = CrashReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = sanitizeDiagnosticsInstallId(policy.installId),
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
exceptionType = throwable::class.simpleName ?: "Throwable",
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
breadcrumbs = breadcrumbs.snapshot(),
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
)
if (report.diagnosticsEnabledAtCapture != false) {
runCatching { store.write(report) }
runCatching {
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
}
}
AppLogger.error("crash", "captured crash ${report.id}", throwable)
return report
}
/**
* Uploads pending crashes that were captured with diagnostics enabled.
* Local files are deleted after successful delivery or bounded by local retention.
*/
suspend fun flushPending() {
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
for (report in store.list()) {
val preferences = preferencesRepository.preferences.first()
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
deleteAllPending()
return
}
if (report.diagnosticsEnabledAtCapture == false) {
store.delete(report.id)
continue
}
val installId = sanitizeDiagnosticsInstallId(
preferences.diagnosticsInstallId.ifBlank {
preferencesRepository.ensureDiagnosticsInstallId()
},
)
val resolved = report.copy(
installId = report.installId.ifBlank { installId },
diagnosticsEnabledAtCapture = true,
)
if (resolved != report) store.write(resolved)
if (
!preferencesRepository.preferences.first().diagnosticsEnabled ||
capturePolicy.load().diagnosticsEnabled == false
) {
deleteAllPending()
return
}
val result = try {
transport.sendCrash(resolved)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
store.delete(resolved.id)
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
store.delete(resolved.id)
continue
}
break
}
}
private fun deleteAllPending() {
store.list().forEach { report -> store.delete(report.id) }
}
companion object {
private const val MaxMessageBytes = 2_000
private const val MaxStackBytes = 32_000
private const val MaxLocalCrashCount = 20
private const val LocalRetentionMillis = 30L * 86_400_000L
}
}
private data class CrashCapturePolicy(
val installId: String = "",
val diagnosticsEnabled: Boolean? = null,
)
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)

View File

@@ -5,83 +5,43 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Owns diagnostics services for the app process: telemetry, crashes, bug reports. * Owns user-initiated bug reports for the app process.
* *
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and * Telemetry and crash auto-reporting were removed; only [bugReports] remains,
* crash auto-reporting are never started; [bugReports] still works for support. * and it only sends when the user submits a report from Settings.
*/ */
class DiagnosticsCoordinator( class DiagnosticsCoordinator(
val preferencesRepository: PreferencesRepository, val preferencesRepository: PreferencesRepository,
val transport: DiagnosticsTransport, val transport: DiagnosticsTransport,
val breadcrumbs: BreadcrumbBuffer,
val telemetry: TelemetryRecorder,
val crashReporter: CrashReporter,
val bugReports: BugReportService, val bugReports: BugReportService,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
) { ) {
fun start() { fun start() {
// Install id is useful for bug-report correlation even without telemetry. // Install id is useful for bug-report correlation.
scope.launch { scope.launch {
preferencesRepository.ensureDiagnosticsInstallId() preferencesRepository.ensureDiagnosticsInstallId()
} }
if (!included) return
crashReporter.startObservingPreferences()
crashReporter.installUnhandledExceptionHandler()
scope.launch {
crashReporter.flushPending()
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!included) return
telemetry.record(name, properties)
} }
companion object { companion object {
fun create( fun create(
appDataDir: String,
appVersion: String, appVersion: String,
platform: String, platform: String,
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
scope: CoroutineScope, scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(), transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
): DiagnosticsCoordinator { ): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer()
val crashStore = createPendingCrashStore(appDataDir)
val telemetry = TelemetryRecorder(
preferencesRepository = preferencesRepository,
transport = transport,
breadcrumbs = breadcrumbs,
scope = scope,
included = included,
)
val crashReporter = CrashReporter(
store = crashStore,
preferencesRepository = preferencesRepository,
transport = transport,
breadcrumbs = breadcrumbs,
appVersion = appVersion,
platform = platform,
scope = scope,
)
val bugReports = BugReportService( val bugReports = BugReportService(
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
transport = transport, transport = transport,
breadcrumbs = breadcrumbs,
appVersion = appVersion, appVersion = appVersion,
platform = platform, platform = platform,
) )
return DiagnosticsCoordinator( return DiagnosticsCoordinator(
preferencesRepository = preferencesRepository, preferencesRepository = preferencesRepository,
transport = transport, transport = transport,
breadcrumbs = breadcrumbs,
telemetry = telemetry,
crashReporter = crashReporter,
bugReports = bugReports, bugReports = bugReports,
scope = scope, scope = scope,
included = included,
) )
} }
} }

View File

@@ -8,85 +8,6 @@ internal object DiagnosticsJson {
internal const val MaxInstallIdBytes = 80 internal const val MaxInstallIdBytes = 80
internal const val MaxAppVersionBytes = 40 internal const val MaxAppVersionBytes = 40
internal const val MaxPlatformBytes = 40 internal const val MaxPlatformBytes = 40
private const val MaxBreadcrumbsJsonBytes = 16_000
private const val MaxBreadcrumbs = 40
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
fun eventsBody(
batchId: String,
installId: String,
appVersion: String,
platform: String,
events: List<TelemetryEvent>,
): String = buildString {
append('{')
appendJsonField("batchId", batchId)
append(',')
appendJsonField("installId", installId)
append(',')
appendJsonField("appVersion", appVersion)
append(',')
appendJsonField("platform", platform)
append(',')
append("\"events\":[")
events.forEachIndexed { index, event ->
if (index > 0) append(',')
append('{')
appendJsonField("name", event.name)
append(',')
append("\"timestampMillis\":")
append(event.timestampMillis)
append(',')
append("\"schemaVersion\":")
append(event.schemaVersion)
append(',')
append("\"properties\":")
appendStringMap(event.properties)
append('}')
}
append("]}")
}
fun eventBatchFitsRequest(events: List<TelemetryEvent>): Boolean =
eventsBody(
batchId = SizedBatchId,
installId = "\u0000".repeat(MaxInstallIdBytes),
appVersion = "\u0000".repeat(MaxAppVersionBytes),
platform = "\u0000".repeat(MaxPlatformBytes),
events = events,
).encodeToByteArray().size <= MaxRequestBytes
fun crashBody(report: CrashReport): String = buildString {
append('{')
appendJsonField("id", report.id)
append(',')
append("\"timestampMillis\":")
append(report.timestampMillis)
append(',')
appendJsonField("installId", report.installId)
append(',')
appendJsonField("appVersion", report.appVersion)
append(',')
appendJsonField("platform", report.platform)
append(',')
appendJsonField("exceptionType", report.exceptionType)
append(',')
appendJsonField("exceptionMessage", report.exceptionMessage)
append(',')
appendJsonField("stackTrace", report.stackTrace)
append(',')
append("\"diagnosticsEnabledAtCapture\":")
append(requireNotNull(report.diagnosticsEnabledAtCapture) {
"crash consent must be resolved before delivery"
})
append(',')
append("\"schemaVersion\":")
append(report.schemaVersion)
append(',')
append("\"breadcrumbs\":")
appendBreadcrumbs(report.breadcrumbs)
append('}')
}
fun bugBody(report: BugReport): String { fun bugBody(report: BugReport): String {
val logs = if (report.includeLogs) report.logs else "" val logs = if (report.includeLogs) report.logs else ""
@@ -149,47 +70,7 @@ internal object DiagnosticsJson {
appendJsonField("network", report.device.network.orEmpty()) appendJsonField("network", report.device.network.orEmpty())
append(',') append(',')
appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty()) appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty())
append("},")
append("\"breadcrumbs\":")
appendBreadcrumbs(report.breadcrumbs)
append('}') append('}')
}
private fun StringBuilder.appendBreadcrumbs(crumbs: List<Breadcrumb>) {
append('[')
var encodedBytes = 2
var appended = 0
for (crumb in crumbs) {
if (appended == MaxBreadcrumbs) break
val name = sanitizeDiagnosticName(crumb.name)
if (name.isBlank() || crumb.timestampMillis < 0) continue
val encoded = buildString {
append('{')
appendJsonField("name", name)
append(',')
append("\"timestampMillis\":")
append(crumb.timestampMillis)
append(',')
append("\"properties\":")
appendStringMap(crumb.properties)
append('}')
}
val additionBytes = encoded.encodeToByteArray().size + if (appended == 0) 0 else 1
if (encodedBytes + additionBytes > MaxBreadcrumbsJsonBytes) break
if (appended > 0) append(',')
append(encoded)
encodedBytes += additionBytes
appended += 1
}
append(']')
}
private fun StringBuilder.appendStringMap(map: Map<String, String>) {
append('{')
sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) ->
if (index > 0) append(',')
appendJsonField(key, value)
}
append('}') append('}')
} }

View File

@@ -5,40 +5,6 @@ package com.vnidrop.app.diagnostics
* intentionally abstracted; nothing here assumes a network backend. * intentionally abstracted; nothing here assumes a network backend.
*/ */
data class TelemetryEvent(
val name: String,
val timestampMillis: Long,
val properties: Map<String, String> = emptyMap(),
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
data class TelemetryBatch(
val id: String,
val events: List<TelemetryEvent>,
)
data class Breadcrumb(
val name: String,
val timestampMillis: Long,
val properties: Map<String, String> = emptyMap(),
)
data class CrashReport(
val id: String,
val timestampMillis: Long,
val installId: String,
val appVersion: String,
val platform: String,
val exceptionType: String,
val exceptionMessage: String,
val stackTrace: String,
val breadcrumbs: List<Breadcrumb>,
/** `null` only while a startup crash is waiting for the persisted preference to load. */
val diagnosticsEnabledAtCapture: Boolean?,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
data class DeviceSnapshot( data class DeviceSnapshot(
val deviceName: String?, val deviceName: String?,
val deviceModel: String?, val deviceModel: String?,
@@ -60,7 +26,6 @@ data class BugReport(
val includeLogs: Boolean, val includeLogs: Boolean,
val logs: String, val logs: String,
val device: DeviceSnapshot, val device: DeviceSnapshot,
val breadcrumbs: List<Breadcrumb>,
val schemaVersion: Int = DiagnosticsSchemaVersion, val schemaVersion: Int = DiagnosticsSchemaVersion,
) )

View File

@@ -1,30 +1,11 @@
package com.vnidrop.app.diagnostics package com.vnidrop.app.diagnostics
internal const val MaxDiagnosticProperties = 12
internal const val MaxDiagnosticPropertyKeyBytes = 40
internal const val MaxDiagnosticPropertyValueBytes = 128
internal const val MaxDiagnosticNameBytes = 64
internal fun sanitizeDiagnosticsInstallId(value: String): String { internal fun sanitizeDiagnosticsInstallId(value: String): String {
val trimmed = value.trim() val trimmed = value.trim()
if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return "" if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return ""
return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes) return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes)
} }
internal fun sanitizeDiagnosticName(name: String): String =
name.takeUtf8Bytes(MaxDiagnosticNameBytes)
internal fun sanitizeDiagnosticProperties(properties: Map<String, String>): Map<String, String> {
val sanitized = LinkedHashMap<String, String>(minOf(properties.size, MaxDiagnosticProperties))
for ((rawKey, rawValue) in properties) {
val key = rawKey.takeUtf8Bytes(MaxDiagnosticPropertyKeyBytes)
if (key.isEmpty() || key in sanitized) continue
sanitized[key] = LogRedactor.redact(rawValue).takeUtf8Bytes(MaxDiagnosticPropertyValueBytes)
if (sanitized.size == MaxDiagnosticProperties) break
}
return sanitized
}
internal fun String.takeUtf8Bytes(maxBytes: Int): String { internal fun String.takeUtf8Bytes(maxBytes: Int): String {
require(maxBytes >= 0) { "maxBytes must not be negative" } require(maxBytes >= 0) { "maxBytes must not be negative" }
val encoded = encodeToByteArray() val encoded = encodeToByteArray()

View File

@@ -1,50 +1,25 @@
package com.vnidrop.app.diagnostics package com.vnidrop.app.diagnostics
/** Network boundary for diagnostics; keep batching and validation client-side. */ /** Network boundary for bug reports; keep validation client-side. */
interface DiagnosticsTransport { interface DiagnosticsTransport {
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
suspend fun sendCrash(report: CrashReport): Result<Unit>
suspend fun sendBugReport(report: BugReport): Result<Unit> suspend fun sendBugReport(report: BugReport): Result<Unit>
} }
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured") internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
this is DiagnosticsPayloadException ||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */ /** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
class NoOpDiagnosticsTransport : DiagnosticsTransport { class NoOpDiagnosticsTransport : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable() override suspend fun sendBugReport(report: BugReport): Result<Unit> =
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable() Result.failure(DiagnosticsUnavailableException())
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
} }
/** /**
* Test double that records calls and can fail on demand. * Test double that records calls and can fail on demand.
*/ */
class RecordingDiagnosticsTransport : DiagnosticsTransport { class RecordingDiagnosticsTransport : DiagnosticsTransport {
val eventBatches = mutableListOf<TelemetryBatch>()
val events: List<List<TelemetryEvent>>
get() = eventBatches.map(TelemetryBatch::events)
val crashes = mutableListOf<CrashReport>()
val bugReports = mutableListOf<BugReport>() val bugReports = mutableListOf<BugReport>()
var eventsResult: Result<Unit> = Result.success(Unit)
var crashResult: Result<Unit> = Result.success(Unit)
var bugResult: Result<Unit> = Result.success(Unit) var bugResult: Result<Unit> = Result.success(Unit)
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
eventBatches += batch
return eventsResult
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
crashes += report
return crashResult
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
bugReports += report bugReports += report
return bugResult return bugResult

View File

@@ -27,33 +27,6 @@ class HttpDiagnosticsTransport(
} }
} }
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
if (batch.events.isEmpty()) return Result.success(Unit)
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
}
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
}
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
val body = DiagnosticsJson.eventsBody(
batch.id,
installId,
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
batch.events,
)
return postJson("/v1/events", body, installId, batch.id)
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
if (report.diagnosticsEnabledAtCapture == null) {
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
}
val body = DiagnosticsJson.crashBody(report)
return postJson("/v1/crashes", body, report.installId, report.id)
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
val body = DiagnosticsJson.bugBody(report) val body = DiagnosticsJson.bugBody(report)
return postJson("/v1/bugs", body, report.installId, report.id) return postJson("/v1/bugs", body, report.installId, report.id)

View File

@@ -1,193 +0,0 @@
package com.vnidrop.app.diagnostics
/**
* Durable crash envelopes written during process death and read on next launch.
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
*/
interface PendingCrashStore {
fun write(report: CrashReport)
fun list(): List<CrashReport>
fun delete(id: String)
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
}
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
internal object CrashReportCodec {
private const val FieldSep = "\u001f"
private const val RecordSep = "\u001e"
private const val Version2Prefix = "vnidrop-crash-v2\n"
private const val MaxEncodedChars = 512 * 1024
fun encode(report: CrashReport): String = buildString {
fun field(key: String, value: String) {
append(key)
append('=')
append(value.hexEncode())
append('\n')
}
append(Version2Prefix)
field("id", report.id)
field("ts", report.timestampMillis.toString())
field("install", report.installId)
field("app", report.appVersion)
field("platform", report.platform)
field("type", report.exceptionType)
field("message", report.exceptionMessage)
field("stack", report.stackTrace)
field(
"diag",
when (report.diagnosticsEnabledAtCapture) {
true -> "1"
false -> "0"
null -> "u"
},
)
field("schema", report.schemaVersion.toString())
val breadcrumbs = report.breadcrumbs.take(40)
field("crumb.count", breadcrumbs.size.toString())
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
field("crumb.$crumbIndex.name", crumb.name)
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
field("crumb.$crumbIndex.prop.count", properties.size.toString())
properties.forEachIndexed { propertyIndex, (key, value) ->
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
}
}
}
fun decode(raw: String): CrashReport? {
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
}
private fun decodeVersion2(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1).hexDecode() ?: return null
map[key] = value
}
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
val crumbs = buildList {
repeat(crumbCount) { crumbIndex ->
val timestamp = map["crumb.$crumbIndex.ts"]
?.toLongOrNull()
?.takeIf { it >= 0 }
?: return null
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
val propertyCount = map["crumb.$crumbIndex.prop.count"]
?.toIntOrNull()
?.takeIf { it in 0..MaxDiagnosticProperties }
?: return null
val properties = buildMap {
repeat(propertyCount) { propertyIndex ->
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
put(key, value)
}
}
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
}
}
return reportFromFields(map, crumbs)
}
private fun decodeLegacy(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.split(FieldSep)) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1)
.replace("\\n", "\n")
.replace("\\r", "\r")
map[key] = value
}
val crumbs = map["crumbs"].orEmpty()
.split(RecordSep)
.filter { it.isNotBlank() }
.mapNotNull { entry ->
val pieces = entry.split('|', limit = 3)
if (pieces.size < 2) return@mapNotNull null
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
pieces[2].split(',').mapNotNull { kv ->
val colon = kv.indexOf(':')
if (colon <= 0) null
else kv.substring(0, colon) to kv.substring(colon + 1)
}.toMap()
} else {
emptyMap()
}
Breadcrumb(name = name, timestampMillis = ts, properties = props)
}
return reportFromFields(map, crumbs)
}
private fun reportFromFields(
map: Map<String, String>,
crumbs: List<Breadcrumb>,
): CrashReport? {
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
val schemaVersion = map["schema"]?.toIntOrNull()
?.takeIf { it == DiagnosticsSchemaVersion }
?: return null
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
"1" -> true
"0" -> false
"u" -> null
else -> return null
}
return CrashReport(
id = id,
timestampMillis = timestamp,
installId = map["install"].orEmpty(),
appVersion = map["app"].orEmpty(),
platform = map["platform"].orEmpty(),
exceptionType = exceptionType,
exceptionMessage = map["message"].orEmpty(),
stackTrace = map["stack"].orEmpty(),
breadcrumbs = crumbs,
diagnosticsEnabledAtCapture = diagnosticsEnabled,
schemaVersion = schemaVersion,
)
}
}
internal fun isValidDiagnosticId(id: String): Boolean =
DiagnosticIdPattern.matches(id)
private val DiagnosticIdPattern =
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
private fun String.hexEncode(): String {
val digits = "0123456789abcdef"
return buildString(length * 2) {
for (byte in this@hexEncode.encodeToByteArray()) {
val value = byte.toInt() and 0xff
append(digits[value ushr 4])
append(digits[value and 0x0f])
}
}
}
private fun String.hexDecode(): String? {
if (length % 2 != 0) return null
val bytes = ByteArray(length / 2)
for (index in bytes.indices) {
val high = this[index * 2].digitToIntOrNull(16) ?: return null
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
bytes[index] = ((high shl 4) or low).toByte()
}
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
}

View File

@@ -1,216 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Product telemetry: sparse events, gated by diagnostics opt-in.
* Events are buffered and flushed in batches when transport is available.
*/
@OptIn(ExperimentalAtomicApi::class)
class TelemetryRecorder(
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val scope: CoroutineScope,
private val maxBufferSize: Int = DefaultMaxBuffer,
private val flushThreshold: Int = DefaultFlushThreshold,
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
private val included: Boolean = true,
) {
private val bufferMutex = Mutex()
private val state = AtomicReference(TelemetryState())
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
init {
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
require(flushThreshold > 0) { "flushThreshold must be positive" }
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
if (included) {
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
updateState { current ->
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
}
flushSignals.trySend(Unit)
}
}
scope.launch { runAutomaticFlushes() }
}
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!included) return
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
breadcrumbs.add(sanitizedName, sanitizedProperties)
val event = TelemetryEvent(
name = sanitizedName,
timestampMillis = platformNowMillis(),
properties = sanitizedProperties,
)
while (true) {
val current = state.load()
if (current.enabled == false) return
val remainingCapacity =
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
flushSignals.trySend(Unit)
return
}
}
}
suspend fun flush(): Result<Unit> {
return bufferMutex.withLock {
var discardedFailure: Throwable? = null
var outcome: Result<Unit>? = null
while (outcome == null) {
val current = state.load()
if (current.enabled != true) return@withLock Result.success(Unit)
val pendingRetry = current.retryBatch
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
if (pendingRetry == null && events.isEmpty()) {
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
continue
}
val batch: TelemetryBatch
if (pendingRetry != null) {
batch = pendingRetry
} else {
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
val next = current.copy(
buffer = current.buffer.drop(events.size),
retryBatch = prepared,
)
if (!state.compareAndSet(current, next)) continue
batch = prepared
}
if (state.load().retryBatch != batch) continue
val result = try {
transport.sendEvents(batch)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
clearRetryBatch(batch)
continue
}
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
if (error.isPermanentDiagnosticsPayloadRejection()) {
clearRetryBatch(batch)
discardedFailure = discardedFailure ?: error
continue
}
outcome = result
}
checkNotNull(outcome)
}
}
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
if (events.isEmpty()) return emptyList()
var minimum = 1
var maximum = minOf(events.size, MaxEventsPerBatch)
var accepted = 1
while (minimum <= maximum) {
val candidateSize = minimum + (maximum - minimum) / 2
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
accepted = candidateSize
minimum = candidateSize + 1
} else {
maximum = candidateSize - 1
}
}
return events.take(accepted)
}
fun pendingCount(): Int {
val current = state.load()
return current.retryBatch?.events.orEmpty().size + current.buffer.size
}
private suspend fun runAutomaticFlushes() {
while (true) {
flushSignals.receive()
var retries = 0
while (true) {
val current = state.load()
if (current.enabled != true || pendingCount() == 0) break
if (current.retryBatch == null && pendingCount() < flushThreshold) {
val signalled = withTimeoutOrNull(flushIntervalMillis) {
flushSignals.receive()
true
} ?: false
if (signalled) continue
}
val result = flush()
if (result.isSuccess || pendingCount() == 0) {
retries = 0
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
break
}
retries += 1
delay(retryBackoffMillis)
}
}
}
private fun clearRetryBatch(batch: TelemetryBatch) {
while (true) {
val current = state.load()
if (current.retryBatch != batch) return
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
}
}
private fun updateState(update: (TelemetryState) -> TelemetryState) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, update(current))) return
}
}
companion object {
const val DefaultMaxBuffer = 100
const val DefaultFlushThreshold = 20
const val MaxEventsPerBatch = 50
const val DefaultFlushIntervalMillis = 30_000L
const val DefaultRetryBackoffMillis = 30_000L
const val DefaultAutomaticRetryCount = 3
}
}
private data class TelemetryState(
val enabled: Boolean? = null,
val buffer: List<TelemetryEvent> = emptyList(),
val retryBatch: TelemetryBatch? = null,
)

View File

@@ -6,7 +6,6 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.AppDependencies import com.vnidrop.app.AppDependencies
import com.vnidrop.app.AppGraph import com.vnidrop.app.AppGraph
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
@@ -38,14 +37,12 @@ class AppViewModel(
private val repository: CoreGateway, private val repository: CoreGateway,
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
private val messages: UiMessageController, private val messages: UiMessageController,
private val diagnostics: DiagnosticsCoordinator? = null,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(AppState()) private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow() val state: StateFlow<AppState> = _state.asStateFlow()
init { init {
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
viewModelScope.launch { viewModelScope.launch {
val relaySettings = preferencesRepository.preferences.first().relaySettings val relaySettings = preferencesRepository.preferences.first().relaySettings
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error) repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
@@ -59,6 +56,5 @@ class AppViewModel(
fun selectDestination(destination: AppDestination) { fun selectDestination(destination: AppDestination) {
_state.update { it.copy(destination = destination) } _state.update { it.copy(destination = destination) }
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
} }
} }

View File

@@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -46,18 +45,15 @@ import vnidrop.shared.generated.resources.about_privacy_title
import vnidrop.shared.generated.resources.about_tagline import vnidrop.shared.generated.resources.about_tagline
import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.device_model_title import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.diagnostics_description
import vnidrop.shared.generated.resources.diagnostics_title
import vnidrop.shared.generated.resources.os_version_title import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title import vnidrop.shared.generated.resources.version_title
private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop" private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL
@Composable @Composable
internal fun AboutSettings( internal fun AboutSettings(
state: SettingsState, state: SettingsState,
onDiagnosticsChanged: (Boolean) -> Unit,
onReportBug: () -> Unit, onReportBug: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
@@ -130,17 +126,6 @@ internal fun AboutSettings(
} }
SettingsGroup { SettingsGroup {
if (DiagnosticsBuildConfig.INCLUDED) {
SettingsToggleRow(
icon = AppIcon.Info,
title = stringResource(Res.string.diagnostics_title),
description = stringResource(Res.string.diagnostics_description),
checked = state.diagnosticsEnabled,
enabled = true,
onCheckedChange = onDiagnosticsChanged,
)
SettingsDivider()
}
SettingsRow( SettingsRow(
icon = AppIcon.Bug, icon = AppIcon.Bug,
title = stringResource(Res.string.about_bug_report), title = stringResource(Res.string.about_bug_report),

View File

@@ -33,7 +33,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onResetFolder = viewModel::resetReceiveFolder, onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled, onNotificationsChanged = viewModel::setNotificationsEnabled,
onOpenNotificationSettings = viewModel::openNotificationSettings, onOpenNotificationSettings = viewModel::openNotificationSettings,
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
onBugWhatChanged = viewModel::setBugWhatHappened, onBugWhatChanged = viewModel::setBugWhatHappened,
onBugExpectedChanged = viewModel::setBugExpected, onBugExpectedChanged = viewModel::setBugExpected,
onBugStepsChanged = viewModel::setBugSteps, onBugStepsChanged = viewModel::setBugSteps,

View File

@@ -23,7 +23,6 @@ fun SettingsScreen(
onResetFolder: () -> Unit, onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit, onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit, onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit,
@@ -62,7 +61,6 @@ fun SettingsScreen(
onResetFolder = onResetFolder, onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged, onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged, onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged, onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged, onBugStepsChanged = onBugStepsChanged,
@@ -105,7 +103,6 @@ fun SettingsScreen(
onResetFolder = onResetFolder, onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged, onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings, onOpenNotificationSettings = onOpenNotificationSettings,
onDiagnosticsChanged = onDiagnosticsChanged,
onBugWhatChanged = onBugWhatChanged, onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged, onBugExpectedChanged = onBugExpectedChanged,
onBugStepsChanged = onBugStepsChanged, onBugStepsChanged = onBugStepsChanged,
@@ -140,7 +137,6 @@ private fun SettingsSectionContent(
onResetFolder: () -> Unit, onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit, onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onDiagnosticsChanged: (Boolean) -> Unit,
onBugWhatChanged: (String) -> Unit, onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit, onBugExpectedChanged: (String) -> Unit,
onBugStepsChanged: (String) -> Unit, onBugStepsChanged: (String) -> Unit,
@@ -184,7 +180,6 @@ private fun SettingsSectionContent(
) )
SettingsSection.About -> AboutSettings( SettingsSection.About -> AboutSettings(
state = state, state = state,
onDiagnosticsChanged = onDiagnosticsChanged,
onReportBug = { onSectionSelected(SettingsSection.BugReport) }, onReportBug = { onSectionSelected(SettingsSection.BugReport) },
onBack = onBack, onBack = onBack,
showBack = showBack, showBack = showBack,

View File

@@ -16,8 +16,6 @@ import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.core.usesCustomRelayUrls
import com.vnidrop.app.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportDraft
import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
@@ -43,8 +41,6 @@ import vnidrop.shared.generated.resources.bug_report_missing_what
import vnidrop.shared.generated.resources.bug_report_submit_failed import vnidrop.shared.generated.resources.bug_report_submit_failed
import vnidrop.shared.generated.resources.bug_report_submitted import vnidrop.shared.generated.resources.bug_report_submitted
import vnidrop.shared.generated.resources.button_open_settings import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.diagnostics_disabled_message
import vnidrop.shared.generated.resources.diagnostics_enabled_message
import vnidrop.shared.generated.resources.notifications_enabled_message import vnidrop.shared.generated.resources.notifications_enabled_message
import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_permission_denied
import vnidrop.shared.generated.resources.notifications_settings_open_failed import vnidrop.shared.generated.resources.notifications_settings_open_failed
@@ -102,7 +98,6 @@ data class SettingsState(
val endpointId: String? = null, val endpointId: String? = null,
val notificationsEnabled: Boolean = false, val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val diagnosticsEnabled: Boolean = false,
val deviceInfo: DeviceInfo? = null, val deviceInfo: DeviceInfo? = null,
val appVersion: String = "", val appVersion: String = "",
val isLoadingDeviceInfo: Boolean = false, val isLoadingDeviceInfo: Boolean = false,
@@ -138,8 +133,6 @@ class SettingsViewModel(
private val notifications: LocalNotificationService, private val notifications: LocalNotificationService,
private val messages: UiMessageController, private val messages: UiMessageController,
private val bugReports: BugReportService, private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null,
private val diagnosticsIncluded: Boolean = DiagnosticsBuildConfig.INCLUDED,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
SettingsState( SettingsState(
@@ -167,7 +160,6 @@ class SettingsViewModel(
receiveFolder = receiveFolder, receiveFolder = receiveFolder,
themeMode = preferences.themeMode, themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled, notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled,
savedRelaySettings = preferences.relaySettings, savedRelaySettings = preferences.relaySettings,
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode, relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
relayUrls = if (hasLocalRelayDraft) { relayUrls = if (hasLocalRelayDraft) {
@@ -512,25 +504,6 @@ class SettingsViewModel(
} }
} }
fun setDiagnosticsEnabled(enabled: Boolean) {
if (!diagnosticsIncluded) return
viewModelScope.launch {
preferencesRepository.setDiagnosticsEnabled(enabled)
diagnostics?.record(
if (enabled) "diagnostics_enabled" else "diagnostics_disabled",
)
messages.show(
UiMessage(
UiText.Resource(
if (enabled) Res.string.diagnostics_enabled_message
else Res.string.diagnostics_disabled_message,
),
UiMessageTone.Success,
),
)
}
}
fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) } fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) } fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) } fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
@@ -565,7 +538,6 @@ class SettingsViewModel(
) )
result.fold( result.fold(
onSuccess = { onSuccess = {
diagnostics?.record("bug_report_submitted")
_state.update { _state.update {
it.copy( it.copy(
isSubmittingBugReport = false, isSubmittingBugReport = false,

View File

@@ -24,9 +24,7 @@ data class AppPreferences(
val receiveFolder: ReceiveFolder, val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val notificationsEnabled: Boolean, val notificationsEnabled: Boolean,
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */ /** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
val diagnosticsEnabled: Boolean = false,
/** Stable anonymous install id; never an account or advertising id. */
val diagnosticsInstallId: String = "", val diagnosticsInstallId: String = "",
val relaySettings: RelaySettings = RelaySettings(), val relaySettings: RelaySettings = RelaySettings(),
) )
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
val receiveFolder: ReceiveFolder, val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false, val notificationsEnabled: Boolean = false,
val diagnosticsEnabled: Boolean = false,
) )
interface PreferencesRepository { interface PreferencesRepository {
@@ -46,7 +43,6 @@ interface PreferencesRepository {
suspend fun resetReceiveFolder() suspend fun resetReceiveFolder()
suspend fun setThemeMode(mode: ThemeMode) suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean) suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setDiagnosticsEnabled(enabled: Boolean)
suspend fun setRelaySettings(settings: RelaySettings) suspend fun setRelaySettings(settings: RelaySettings)
/** Ensures a durable install id exists and returns it. */ /** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String suspend fun ensureDiagnosticsInstallId(): String
@@ -83,7 +79,6 @@ class AppPreferencesRepository(
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(), diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
relaySettings = RelaySettings( relaySettings = RelaySettings(
mode = relayMode, mode = relayMode,
@@ -122,12 +117,6 @@ class AppPreferencesRepository(
} }
} }
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
}
}
override suspend fun setRelaySettings(settings: RelaySettings) { override suspend fun setRelaySettings(settings: RelaySettings) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PreferenceKeys.RelayMode] = settings.mode.name prefs[PreferenceKeys.RelayMode] = settings.mode.name
@@ -160,7 +149,6 @@ private object PreferenceKeys {
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name") val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode") val ThemeMode = stringPreferencesKey("theme_mode")
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id") val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
val RelayMode = stringPreferencesKey("relay_mode") val RelayMode = stringPreferencesKey("relay_mode")
val RelayUrls = stringPreferencesKey("relay_urls") val RelayUrls = stringPreferencesKey("relay_urls")

View File

@@ -0,0 +1,63 @@
package com.vnidrop.app.background
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.support.FakeCoreGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class BackgroundSharingCoordinatorTest {
@Test
fun keepsThePlatformActiveOnlyWhileAnOutgoingShareIsAvailable() = runTest {
val core = FakeCoreGateway()
val activeStates = mutableListOf<Boolean>()
val coordinator = BackgroundSharingCoordinator(
repository = core,
controller = BackgroundSharingController(activeStates::add),
scope = backgroundScope,
)
runCurrent()
core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Receive, TransferStatus.Receiving)))
runCurrent()
assertEquals(listOf(false), activeStates)
core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Importing)))
runCurrent()
assertEquals(listOf(false, true), activeStates)
core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Sharing)))
runCurrent()
assertEquals(listOf(false, true), activeStates)
core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Stopped)))
runCurrent()
assertEquals(listOf(false, true, false), activeStates)
coordinator.stop()
assertEquals(listOf(false, true, false, false), activeStates)
}
private fun transfer(direction: TransferDirection, status: TransferStatus) = Transfer(
localId = "local",
transferId = 1UL,
direction = direction,
status = status,
peerId = null,
transferName = "Photos",
contentHash = "hash",
fileCount = 1UL,
totalSize = 1UL,
ticket = "ticket",
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -4,83 +4,25 @@ import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DiagnosticsTest { class DiagnosticsTest {
@Test
fun diagnosticsJsonEscapesAndShapesPayloads() {
val eventsJson = DiagnosticsJson.eventsBody(
batchId = "batch-1",
installId = "inst-1",
appVersion = "1.0",
platform = "Test",
events = listOf(
TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")),
),
)
assertTrue(eventsJson.contains("\"batchId\":\"batch-1\""))
assertTrue(eventsJson.contains("\"installId\":\"inst-1\""))
assertTrue(eventsJson.contains("\"name\":\"app_open\""))
assertTrue(eventsJson.contains("quote\\\"here"))
val crashJson = DiagnosticsJson.crashBody(
CrashReport(
id = "c1",
timestampMillis = 1L,
installId = "inst",
appVersion = "1.0",
platform = "Test",
exceptionType = "E",
exceptionMessage = "line\nbreak",
stackTrace = "stack",
breadcrumbs = emptyList(),
diagnosticsEnabledAtCapture = true,
),
)
assertTrue(crashJson.contains("line\\nbreak"))
assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true"))
}
@Test @Test
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() { fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
val report = BugReport( val report = bugReport(
id = "b",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = true,
logs = "\n".repeat(BugReportService.MaxLogBytes), logs = "\n".repeat(BugReportService.MaxLogBytes),
device = DeviceSnapshot(null, null, "OS", null, null), includeLogs = true,
breadcrumbs = emptyList(),
) )
val body = DiagnosticsJson.bugBody(report) val body = DiagnosticsJson.bugBody(report)
@@ -91,67 +33,24 @@ class DiagnosticsTest {
} }
@Test @Test
fun httpTransportPostsExpectedPaths() = runTest { fun httpTransportPostsBugReportToExpectedPath() = runTest {
val calls = mutableListOf<Pair<String, String>>() val calls = mutableListOf<Pair<String, String>>()
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
val transport = HttpDiagnosticsTransport( val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example", baseUrl = "https://diag.example",
ingestKey = "secret", ingestKey = "secret",
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
installIdProvider = { "install-x" },
post = { url, headers, body -> post = { url, headers, body ->
assertEquals("secret", headers["X-VniDrop-Key"]) assertEquals("secret", headers["X-VniDrop-Key"])
calls += url to body calls += url to body
PlatformHttpResponse( PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":1}""")
202,
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""",
)
}, },
) )
val eventResult = transport.sendEvents(
TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))),
)
assertTrue(eventResult.isSuccess)
assertEquals("https://diag.example/v1/events", calls[0].first)
assertTrue(calls[0].second.contains("install-x"))
val crashResult = transport.sendCrash( val bugResult = transport.sendBugReport(bugReport())
CrashReport(
id = "c",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
exceptionType = "E",
exceptionMessage = "m",
stackTrace = "s",
breadcrumbs = emptyList(),
diagnosticsEnabledAtCapture = true,
),
)
assertTrue(crashResult.isSuccess)
assertEquals("https://diag.example/v1/crashes", calls[1].first)
val bugResult = transport.sendBugReport(
BugReport(
id = "b",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = false,
logs = "",
device = DeviceSnapshot(null, null, "OS", null, null),
breadcrumbs = emptyList(),
),
)
assertTrue(bugResult.isSuccess) assertTrue(bugResult.isSuccess)
assertEquals("https://diag.example/v1/bugs", calls[2].first) assertEquals("https://diag.example/v1/bugs", calls.single().first)
} }
@Test @Test
@@ -162,12 +61,12 @@ class DiagnosticsTest {
post = { _, _, _ -> post = { _, _, _ ->
PlatformHttpResponse( PlatformHttpResponse(
202, 202,
"""{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""", """{"ok":true,"id":"b","metadata":{"stored":1,"flags":[true,null]}}""",
) )
}, },
) )
assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess) assertTrue(transport.sendBugReport(bugReport()).isSuccess)
} }
@Test @Test
@@ -177,9 +76,7 @@ class DiagnosticsTest {
ingestKey = "secret", ingestKey = "secret",
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") }, post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
) )
assertTrue( assertTrue(transport.sendBugReport(bugReport()).isFailure)
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
)
} }
@Test @Test
@@ -191,7 +88,7 @@ class DiagnosticsTest {
) )
assertFailsWith<CancellationException> { assertFailsWith<CancellationException> {
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1)))) transport.sendBugReport(bugReport())
} }
} }
@@ -203,7 +100,7 @@ class DiagnosticsTest {
PlatformHttpResponse(202, """{"ok":false}"""), PlatformHttpResponse(202, """{"ok":false}"""),
PlatformHttpResponse(202, """{,"ok":true}"""), PlatformHttpResponse(202, """{,"ok":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""), PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""), PlatformHttpResponse(202, """{"ok":true,"id":"b","id":"b"}"""),
), ),
) )
val transport = HttpDiagnosticsTransport( val transport = HttpDiagnosticsTransport(
@@ -212,10 +109,8 @@ class DiagnosticsTest {
post = { _, _, _ -> responses.removeFirst() }, post = { _, _, _ -> responses.removeFirst() },
) )
repeat(5) { index -> repeat(5) {
val result = transport.sendEvents( val result = transport.sendBugReport(bugReport())
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull()) assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
} }
} }
@@ -224,11 +119,11 @@ class DiagnosticsTest {
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest { fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
val responses = ArrayDeque( val responses = ArrayDeque(
listOf( listOf(
PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""), PlatformHttpResponse(202, """{"ok":true,"ok":true,"id":"b"}"""),
PlatformHttpResponse(202, """{"ok":true,"id":true}"""), PlatformHttpResponse(202, """{"ok":true,"id":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""), PlatformHttpResponse(202, """{"ok":true,"id":"b","stored":01}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""), PlatformHttpResponse(202, """{"ok":true,"id":"b",}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""), PlatformHttpResponse(202, """{"ok":true,"id":"b"} trailing"""),
), ),
) )
val transport = HttpDiagnosticsTransport( val transport = HttpDiagnosticsTransport(
@@ -237,10 +132,8 @@ class DiagnosticsTest {
post = { _, _, _ -> responses.removeFirst() }, post = { _, _, _ -> responses.removeFirst() },
) )
repeat(5) { index -> repeat(5) {
val result = transport.sendEvents( val result = transport.sendBugReport(bugReport())
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull()) assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
} }
} }
@@ -288,9 +181,7 @@ class DiagnosticsTest {
@Test @Test
fun noOpTransportReportsUnavailableDelivery() = runTest { fun noOpTransportReportsUnavailableDelivery() = runTest {
val result = NoOpDiagnosticsTransport().sendEvents( val result = NoOpDiagnosticsTransport().sendBugReport(bugReport())
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull()) assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
} }
@@ -313,575 +204,11 @@ class DiagnosticsTest {
assertTrue(redacted.contains("[redacted-endpoint]")) assertTrue(redacted.contains("[redacted-endpoint]"))
} }
@Test
fun breadcrumbBufferKeepsOnlyLatestEntries() {
val buffer = BreadcrumbBuffer(capacity = 3)
buffer.add("a")
buffer.add("b")
buffer.add("c")
buffer.add("d")
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name })
}
@Test
fun telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
val transport = RecordingDiagnosticsTransport()
val breadcrumbs = BreadcrumbBuffer()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = breadcrumbs,
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 1,
)
advanceUntilIdle()
recorder.record("app_open")
advanceUntilIdle()
assertEquals(0, transport.events.size)
assertEquals(1, breadcrumbs.snapshot().size)
}
@Test
fun excludedTelemetryDoesNotStartOrRecordEvents() = runTest {
val transport = RecordingDiagnosticsTransport()
val breadcrumbs = BreadcrumbBuffer()
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = breadcrumbs,
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 1,
included = false,
)
recorder.record("app_open")
advanceUntilIdle()
assertEquals(0, recorder.pendingCount())
assertTrue(transport.events.isEmpty())
assertTrue(breadcrumbs.snapshot().isEmpty())
}
@Test
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
val backing = fakePrefs(diagnosticsEnabled = true)
val preferenceGate = CompletableDeferred<Unit>()
val delayedPreferences = object : PreferencesRepository by backing {
override val preferences = flow {
preferenceGate.await()
emitAll(backing.preferences)
}
}
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = delayedPreferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 20,
)
runCurrent()
recorder.record("app_open")
assertEquals(1, recorder.pendingCount())
preferenceGate.complete(Unit)
runCurrent()
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf("app_open"), transport.events.single().map { it.name })
}
@Test
fun telemetryBuffersAndFlushesWhenEnabled() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 2,
maxBufferSize = 10,
)
advanceUntilIdle()
recorder.record("one")
recorder.record("two")
advanceUntilIdle()
assertEquals(1, transport.events.size)
assertEquals(listOf("one", "two"), transport.events.single().map { it.name })
}
@Test
fun telemetryFlushesSparseEventsAfterTheInterval() = runTest {
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 20,
flushIntervalMillis = 1_000,
)
advanceUntilIdle()
recorder.record("sparse")
advanceTimeBy(999)
runCurrent()
assertTrue(transport.eventBatches.isEmpty())
advanceTimeBy(1)
runCurrent()
assertEquals(listOf("sparse"), transport.events.single().map { it.name })
}
@Test
fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest {
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(IllegalStateException("offline"))
}
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 1,
flushIntervalMillis = 10_000,
retryBackoffMillis = 1_000,
automaticRetryCount = 1,
)
advanceUntilIdle()
recorder.record("retry")
runCurrent()
assertEquals(1, transport.eventBatches.size)
advanceTimeBy(999)
runCurrent()
assertEquals(1, transport.eventBatches.size)
advanceTimeBy(1)
runCurrent()
assertEquals(2, transport.eventBatches.size)
advanceTimeBy(10_000)
runCurrent()
assertEquals(2, transport.eventBatches.size)
}
@Test
fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
repeat(75) { recorder.record("event-$it") }
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size })
assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch })
}
@Test
fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val requestBodies = mutableListOf<String>()
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "test-install" },
post = { _, _, body ->
requestBodies += body
val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1)
PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""")
},
)
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
val properties = LinkedHashMap<String, String>().apply {
repeat(MaxDiagnosticProperties) { index ->
put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes))
}
}
repeat(50) { index ->
recorder.record("event-$index-${"\u0001".repeat(64)}", properties)
}
assertTrue(recorder.flush().isSuccess)
assertTrue(requestBodies.size > 1)
assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes })
assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() })
}
@Test
fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 100,
)
advanceUntilIdle()
val properties = LinkedHashMap<String, String>().apply {
repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) }
}
recorder.record("🙂".repeat(100), properties)
assertTrue(recorder.flush().isSuccess)
val event = transport.eventBatches.single().events.single()
assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes)
assertEquals(MaxDiagnosticProperties, event.properties.size)
assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes })
assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes })
}
@Test
fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
coroutineScope {
repeat(100) { index ->
launch(Dispatchers.Default) { recorder.record("event-$index") }
}
}
assertEquals(
100,
recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size },
)
assertTrue(recorder.flush().isSuccess)
assertEquals(100, transport.eventBatches.sumOf { it.events.size })
}
@Test
fun telemetryRetryReusesBatchIdAndEvents() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(IllegalStateException("offline"))
}
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 10,
)
advanceUntilIdle()
recorder.record("one")
recorder.record("two")
assertTrue(recorder.flush().isFailure)
val firstAttempt = transport.eventBatches.single()
transport.eventsResult = Result.success(Unit)
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches)
assertEquals(0, recorder.pendingCount())
}
@Test
fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events"))
}
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 10,
)
advanceUntilIdle()
recorder.record("invalid")
assertTrue(recorder.flush().isFailure)
assertEquals(0, recorder.pendingCount())
transport.eventsResult = Result.success(Unit)
assertTrue(recorder.flush().isSuccess)
assertEquals(1, transport.eventBatches.size)
}
@Test
fun telemetryClearsBufferWhenOptedOut() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 100,
)
advanceUntilIdle()
recorder.record("queued")
assertEquals(1, recorder.pendingCount())
preferences.setDiagnosticsEnabled(false)
advanceUntilIdle()
assertEquals(0, recorder.pendingCount())
}
@Test
fun crashCodecRoundTrips() {
val original = CrashReport(
id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
timestampMillis = 42L,
installId = "install",
appVersion = "1.0",
platform = "Test",
exceptionType = "IllegalStateException",
exceptionMessage = "boom\nline\\nliteral\u001fseparator",
stackTrace = "stack\ntrace\u001erecord",
breadcrumbs = listOf(
Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")),
),
diagnosticsEnabledAtCapture = true,
)
val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original))
assertEquals(original, decoded)
assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape"))))
}
@Test
fun crashCodecMigratesLegacyV1Envelope() {
val raw = listOf(
"id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"ts=42",
"install=legacy-install",
"app=0.9",
"platform=Desktop",
"type=IllegalStateException",
"message=first\\nsecond",
"stack=frame one\\nframe two",
"diag=1",
"schema=1",
"crumbs=1|opened|screen:send",
).joinToString("\u001f")
val report = requireNotNull(CrashReportCodec.decode(raw))
assertEquals("first\nsecond", report.exceptionMessage)
assertEquals("frame one\nframe two", report.stackTrace)
assertEquals(true, report.diagnosticsEnabledAtCapture)
assertEquals(
listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))),
report.breadcrumbs,
)
}
@Test
fun crashReporterPersistsAndFlushesOnlyOptedInCrashes() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.startObservingPreferences()
advanceUntilIdle()
val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true)
reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false)
assertEquals(1, store.list().size)
reporter.flushPending()
assertEquals(1, transport.crashes.size)
assertEquals(optedIn.id, transport.crashes.single().id)
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = NoOpDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true)
reporter.flushPending()
assertEquals(1, store.list().size)
}
@Test
fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val permanentTransport = RecordingDiagnosticsTransport().apply {
crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes"))
}
val permanentStore = InMemoryPendingCrashStore()
val permanentReporter = CrashReporter(
store = permanentStore,
preferencesRepository = preferences,
transport = permanentTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true)
permanentReporter.flushPending()
assertTrue(permanentStore.list().isEmpty())
val transientTransport = RecordingDiagnosticsTransport().apply {
crashResult = Result.failure(IllegalStateException("offline"))
}
val transientStore = InMemoryPendingCrashStore()
val transientReporter = CrashReporter(
store = transientStore,
preferencesRepository = preferences,
transport = transientTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
repeat(2) {
transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true)
}
transientReporter.flushPending()
assertEquals(1, transientTransport.crashes.size)
assertEquals(2, transientStore.list().size)
}
@Test
fun crashReporterResolvesStartupConsentBeforeUploading() = runTest {
val transport = RecordingDiagnosticsTransport()
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
val startupCrash = reporter.capture(RuntimeException("startup"))
assertNull(startupCrash.diagnosticsEnabledAtCapture)
reporter.flushPending()
assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture)
assertEquals("test-install", transport.crashes.single().installId)
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
val transport = RecordingDiagnosticsTransport()
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.capture(RuntimeException("startup"))
reporter.flushPending()
assertTrue(store.list().isEmpty())
preferences.setDiagnosticsEnabled(true)
reporter.flushPending()
assertTrue(transport.crashes.isEmpty())
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val firstSendStarted = CompletableDeferred<Unit>()
val releaseFirstSend = CompletableDeferred<Unit>()
val sentIds = mutableListOf<String>()
val transport = object : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport) = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
sentIds += report.id
if (sentIds.size == 1) {
firstSendStarted.complete(Unit)
releaseFirstSend.await()
}
return Result.success(Unit)
}
}
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.startObservingPreferences()
advanceUntilIdle()
repeat(2) {
reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true)
}
val flush = launch { reporter.flushPending() }
firstSendStarted.await()
preferences.setDiagnosticsEnabled(false)
runCurrent()
releaseFirstSend.complete(Unit)
flush.join()
assertEquals(1, sentIds.size)
assertTrue(store.list().isEmpty())
}
@Test @Test
fun bugReportRequiresWhatAndExpected() = runTest { fun bugReportRequiresWhatAndExpected() = runTest {
val preferences = fakePrefs()
val service = BugReportService( val service = BugReportService(
preferencesRepository = preferences, preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(), transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
logReader = { "logs" }, logReader = { "logs" },
@@ -893,8 +220,6 @@ class DiagnosticsTest {
@Test @Test
fun bugReportConvertsTransportExceptionsToFailure() = runTest { fun bugReportConvertsTransportExceptionsToFailure() = runTest {
val throwingTransport = object : DiagnosticsTransport { val throwingTransport = object : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport) = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport): Result<Unit> { override suspend fun sendBugReport(report: BugReport): Result<Unit> {
throw IllegalStateException("offline") throw IllegalStateException("offline")
} }
@@ -902,7 +227,6 @@ class DiagnosticsTest {
val service = BugReportService( val service = BugReportService(
preferencesRepository = fakePrefs(), preferencesRepository = fakePrefs(),
transport = throwingTransport, transport = throwingTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
) )
@@ -914,13 +238,11 @@ class DiagnosticsTest {
} }
@Test @Test
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest { fun bugReportSubmitsWithRedactedLogs() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
val transport = RecordingDiagnosticsTransport() val transport = RecordingDiagnosticsTransport()
val service = BugReportService( val service = BugReportService(
preferencesRepository = preferences, preferencesRepository = fakePrefs(),
transport = transport, transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") }, logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") },
@@ -951,7 +273,6 @@ class DiagnosticsTest {
val service = BugReportService( val service = BugReportService(
preferencesRepository = fakePrefs(), preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(), transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
logReader = { rawLogs }, logReader = { rawLogs },
@@ -969,7 +290,6 @@ class DiagnosticsTest {
val service = BugReportService( val service = BugReportService(
preferencesRepository = fakePrefs(), preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(), transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
logReader = { "🙂".repeat(60_000) }, logReader = { "🙂".repeat(60_000) },
@@ -981,38 +301,34 @@ class DiagnosticsTest {
assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes()) assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes())
} }
private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository( private fun bugReport(
id: String = "b",
logs: String = "",
includeLogs: Boolean = false,
) = BugReport(
id = id,
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = includeLogs,
logs = logs,
device = DeviceSnapshot(null, null, "OS", null, null),
)
private fun fakePrefs() = FakePreferencesRepository(
AppPreferences( AppPreferences(
username = "User", username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"), receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = diagnosticsEnabled,
diagnosticsInstallId = "test-install", diagnosticsInstallId = "test-install",
), ),
) )
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%") private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
} }
private class InMemoryPendingCrashStore : PendingCrashStore {
private val items = linkedMapOf<String, CrashReport>()
override fun write(report: CrashReport) {
items[report.id] = report
}
override fun list(): List<CrashReport> = items.values.sortedByDescending { it.timestampMillis }
override fun delete(id: String) {
items.remove(id)
}
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
items.values
.sortedByDescending { it.timestampMillis }
.drop(maxCount)
.map(CrashReport::id)
.forEach(items::remove)
items.values
.filter { it.timestampMillis < olderThanTimestampMillis }
.map(CrashReport::id)
.forEach(items::remove)
}
}

View File

@@ -18,7 +18,6 @@ import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.diagnostics.BreadcrumbBuffer
import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.DiagnosticsTransport import com.vnidrop.app.diagnostics.DiagnosticsTransport
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
@@ -420,33 +419,6 @@ class ViewModelsTest {
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission) assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
} }
@Test
fun settingsTogglesDiagnosticsPreference() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences, diagnosticsIncluded = true)
advanceUntilIdle()
assertFalse(viewModel.state.value.diagnosticsEnabled)
viewModel.setDiagnosticsEnabled(true)
advanceUntilIdle()
assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled)
assertTrue(viewModel.state.value.diagnosticsEnabled)
}
@Test
fun settingsIgnoresDiagnosticsOptInWhenExcluded() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = settingsViewModel(preferences)
advanceUntilIdle()
viewModel.setDiagnosticsEnabled(true)
advanceUntilIdle()
assertFalse(preferences.mutablePreferences.value.diagnosticsEnabled)
assertFalse(viewModel.state.value.diagnosticsEnabled)
}
@Test @Test
fun settingsSubmitsBugReportAndClearsForm() = runTest { fun settingsSubmitsBugReportAndClearsForm() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -993,7 +965,6 @@ class ViewModelsTest {
receiveFolder = folder, receiveFolder = folder,
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
notificationsEnabled = false, notificationsEnabled = false,
diagnosticsEnabled = false,
diagnosticsInstallId = "test-install", diagnosticsInstallId = "test-install",
), ),
) )
@@ -1005,7 +976,6 @@ class ViewModelsTest {
notifications: FakeNotificationService = FakeNotificationService(), notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder), fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
diagnosticsIncluded: Boolean = false,
repository: FakeCoreGateway = FakeCoreGateway(), repository: FakeCoreGateway = FakeCoreGateway(),
) = SettingsViewModel( ) = SettingsViewModel(
environment(), environment(),
@@ -1018,12 +988,10 @@ class ViewModelsTest {
BugReportService( BugReportService(
preferencesRepository = preferences, preferencesRepository = preferences,
transport = transport, transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0", appVersion = "1.0",
platform = "Test", platform = "Test",
logReader = { "sample log line" }, logReader = { "sample log line" },
), ),
diagnosticsIncluded = diagnosticsIncluded,
) )
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer( private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(

View File

@@ -210,9 +210,6 @@ class FakePreferencesRepository(
override suspend fun resetReceiveFolder() = Unit override suspend fun resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) } override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) } override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
}
override suspend fun setRelaySettings(settings: RelaySettings) { override suspend fun setRelaySettings(settings: RelaySettings) {
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings) mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
} }

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.background.BackgroundSharingController
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.JvmLocalNotificationService import com.vnidrop.app.notifications.JvmLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
@@ -22,6 +23,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
deviceInfoProvider = JvmDeviceInfoProvider, deviceInfoProvider = JvmDeviceInfoProvider,
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = JvmLocalNotificationService(), localNotificationService = JvmLocalNotificationService(),
backgroundSharingController = BackgroundSharingController { },
externalInvitations = externalInvitations, externalInvitations = externalInvitations,
) )
} }

View File

@@ -1,78 +0,0 @@
package com.vnidrop.app.diagnostics
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
JvmPendingCrashStore(appDataDir)
private class JvmPendingCrashStore(
appDataDir: String,
) : PendingCrashStore {
private val directory = File(appDataDir, "diagnostics/crashes")
@Synchronized
override fun write(report: CrashReport) {
if (!isValidDiagnosticId(report.id)) return
directory.mkdirs()
val target = File(directory, "${report.id}.crash")
val temporary = File(directory, ".${report.id}.tmp")
val payload = CrashReportCodec.encode(report)
temporary.writeText(payload, StandardCharsets.UTF_8)
if (!temporary.renameTo(target)) {
target.writeText(payload, StandardCharsets.UTF_8)
temporary.delete()
}
}
@Synchronized
override fun list(): List<CrashReport> {
if (!directory.isDirectory) return emptyList()
return directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.mapNotNull { file ->
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
}
.sortedWith(
compareByDescending<CrashReport> { it.timestampMillis }
.thenBy { it.id },
)
}
@Synchronized
override fun delete(id: String) {
if (!isValidDiagnosticId(id)) return
File(directory, "$id.crash").delete()
}
@Synchronized
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
require(maxCount > 0) { "maxCount must be positive" }
if (!directory.isDirectory) return
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
.orEmpty()
.forEach(File::delete)
val reports = directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.mapNotNull { file ->
val report = runCatching {
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
}.getOrNull()
if (report == null) {
file.delete()
null
} else {
file to report
}
}
.sortedWith(
compareByDescending<Pair<File, CrashReport>> { (_, report) -> report.timestampMillis }
.thenBy { (_, report) -> report.id },
)
reports.forEachIndexed { index, (file, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
}
}
}

View File

@@ -1,9 +0,0 @@
package com.vnidrop.app.diagnostics
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching { onCrash(throwable) }
previous?.uncaughtException(thread, throwable)
}
}

Some files were not shown because too many files have changed in this diff Show More