From b4aa0589b42e996a2e63388e8d96a5368d9de642 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Tue, 19 May 2026 16:08:56 +0200 Subject: [PATCH 1/2] Refine analytics onboarding dismissal animation --- .gitignore | 7 +- README.md | 19 + lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 24 + lib/l10n/app_es.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_it.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_tr.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/l10n/app_zh_Hant.arb | 6 + lib/l10n/generated/app_localizations.dart | 36 + lib/l10n/generated/app_localizations_de.dart | 20 + lib/l10n/generated/app_localizations_en.dart | 20 + lib/l10n/generated/app_localizations_es.dart | 20 + lib/l10n/generated/app_localizations_fr.dart | 20 + lib/l10n/generated/app_localizations_it.dart | 20 + lib/l10n/generated/app_localizations_pt.dart | 20 + lib/l10n/generated/app_localizations_ru.dart | 20 + lib/l10n/generated/app_localizations_tr.dart | 20 + lib/l10n/generated/app_localizations_zh.dart | 40 + lib/main.dart | 30 +- .../analytics_onboarding_provider.dart | 49 + lib/providers/product_analytics_provider.dart | 35 + lib/screens/chat_list_screen.dart | 109 +- lib/screens/privacy_security_screen.dart | 182 ++- lib/services/foreground_service.dart | 3 + lib/services/product_analytics_config.dart | 128 ++ lib/services/product_analytics_service.dart | 168 +++ lib/src/rust/api.dart | 17 +- lib/src/rust/api/groups.dart | 8 +- lib/src/rust/api/media_files.dart | 15 +- lib/src/rust/api/messages.dart | 3 +- lib/src/rust/api/product_analytics.dart | 254 ++++ .../rust/api/product_analytics.freezed.dart | 272 ++++ lib/src/rust/frb_generated.dart | 1098 +++++++++++++-- lib/src/rust/frb_generated.io.dart | 298 ++++ lib/widgets/wn_slate.dart | 12 +- lib/widgets/wn_system_notice.dart | 74 +- lib/widgets/wn_toggle.dart | 59 + pubspec.lock | 2 +- .../PR-517-start-chat-flow-improvements.md | 201 --- rust/Cargo.lock | 22 +- rust/Cargo.toml | 3 +- rust/src/api/groups.rs | 3 + rust/src/api/media_files.rs | 6 + rust/src/api/messages.rs | 178 ++- rust/src/api/mod.rs | 16 +- rust/src/api/product_analytics.rs | 357 +++++ rust/src/frb_generated.rs | 1250 +++++++++++++++-- test/main_test.dart | 66 + test/mocks/mock_secure_storage.dart | 8 +- test/mocks/mock_wn_api.dart | 81 ++ .../analytics_onboarding_provider_test.dart | 64 + test/screens/chat_list_screen_test.dart | 243 +++- .../screens/privacy_security_screen_test.dart | 77 +- test/screens/settings_screen_test.dart | 6 + test/screens/signup_screen_test.dart | 11 +- .../product_analytics_config_test.dart | 94 ++ .../product_analytics_service_test.dart | 137 ++ test/widgets/wn_slate_test.dart | 47 + test/widgets/wn_system_notice_test.dart | 70 + test/widgets/wn_toggle_test.dart | 48 + widgetbook/lib/components/wn_toggle.dart | 132 ++ widgetbook/lib/main.directories.g.dart | 12 + 66 files changed, 5712 insertions(+), 576 deletions(-) create mode 100644 lib/providers/analytics_onboarding_provider.dart create mode 100644 lib/providers/product_analytics_provider.dart create mode 100644 lib/services/product_analytics_config.dart create mode 100644 lib/services/product_analytics_service.dart create mode 100644 lib/src/rust/api/product_analytics.dart create mode 100644 lib/src/rust/api/product_analytics.freezed.dart create mode 100644 lib/widgets/wn_toggle.dart delete mode 100644 reviews/PR-517-start-chat-flow-improvements.md create mode 100644 rust/src/api/product_analytics.rs create mode 100644 test/providers/analytics_onboarding_provider_test.dart create mode 100644 test/services/product_analytics_config_test.dart create mode 100644 test/services/product_analytics_service_test.dart create mode 100644 test/widgets/wn_toggle_test.dart create mode 100644 widgetbook/lib/components/wn_toggle.dart diff --git a/.gitignore b/.gitignore index ebd22be77..de1e6cdc8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ app.*.map.json /android/app/pkg/sumdb/ /android/key.properties /android/key-staging.properties +/android/app/google-services.json +/android/app/src/*/google-services.json *.jks *.keystore @@ -76,8 +78,11 @@ docs/plans/ # Local relay data created by `docker compose up`. /dev/data/ +# Local product analytics dart-defines. +.env.analytics* + # Reviews -reviews/ +/reviews/ # AI tools opencode.json diff --git a/README.md b/README.md index 41cb62c1c..efe5c307b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,25 @@ just deps # Install Flutter and Rust dependencies just run # Run on a connected device (staging flavor) ``` +## Product Analytics + +Product analytics are opt-in only and delivered by `whitenoise-rs`; Flutter does not call Aptabase directly. To enable a staging or production analytics build, pass the self-hosted Aptabase base URL and the flavor-specific app key as Dart defines: + +```sh +flutter run --flavor staging \ + --dart-define=APTABASE_HOST=https://analytics.example.com \ + --dart-define=APTABASE_APP_KEY=staging-app-key +``` + +For local builds, a Flutter dart-define file is usually less error-prone: + +```sh +flutter run --flavor staging --dart-define-from-file=.env.analytics.staging.local.json +flutter run --flavor production --dart-define-from-file=.env.analytics.production.local.json +``` + +Use separate staging and production app keys. Keep real values in CI secrets or local ignored `.env.analytics*.json` files; do not commit them. + ## Structure ``` diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index be333cea4..351f67e82 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Profil einrichten", "createProfile": "Profil erstellen", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Name", "enterYourName": "Namen eingeben", "introduceYourself": "Über dich", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ed20159c..81e2d18ed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -66,6 +66,30 @@ "@createProfile": { "description": "Submit button text on signup screen" }, + "analyticsConsentTitle": "Help improve White Noise", + "@analyticsConsentTitle": { + "description": "Title for the optional product analytics consent checkbox" + }, + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", + "@analyticsConsentDescription": { + "description": "Description for the optional product analytics consent checkbox" + }, + "@analyticsConsentSettingsDescription": { + "description": "Description for the product analytics setting row" + }, + "@analyticsPromptTitle": { + "description": "Title for the product analytics prompt on the empty chat list" + }, + "@analyticsPromptNotNow": { + "description": "Decline button for the product analytics prompt" + }, + "@analyticsPromptShare": { + "description": "Opt-in button for the product analytics prompt" + }, "chooseName": "Name", "@chooseName": { "description": "Label for name input field" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b0456b5cf..da4327f11 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurar perfil", "createProfile": "Crear perfil", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Nombre", "enterYourName": "Introduce tu nombre", "introduceYourself": "Acerca de", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ec0e3cbd7..d8e28c1c8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurer le profil", "createProfile": "Créer le profil", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Nom", "enterYourName": "Entrez votre nom", "introduceYourself": "À propos", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d858def81..84294cca4 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configura profilo", "createProfile": "Crea profilo", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Nome", "enterYourName": "Inserisci il tuo nome", "introduceYourself": "Informazioni", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4e9f8e012..0f9971a33 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurar perfil", "createProfile": "Criar perfil", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Nome", "enterYourName": "Digite seu nome", "introduceYourself": "Sobre", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4bfcc62e8..f840fdd12 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Настройка профиля", "createProfile": "Создать профиль", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "Имя", "enterYourName": "Введите ваше имя", "introduceYourself": "О себе", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index cbf7e7fc4..761990491 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Profil oluştur", "createProfile": "Profil oluştur", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "İsim", "enterYourName": "Adınızı girin", "introduceYourself": "Hakkında", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cfaba771c..5fb1a7b6b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "设置个人资料", "createProfile": "创建个人资料", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "名称", "enterYourName": "输入您的名称", "introduceYourself": "简介", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 036b6f68f..81e47a260 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -14,6 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "設定個人檔案", "createProfile": "建立個人檔案", + "analyticsConsentTitle": "Help improve White Noise", + "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", + "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", + "analyticsPromptTitle": "Help improve White Noise?", + "analyticsPromptNotNow": "Not now", + "analyticsPromptShare": "Share analytics", "chooseName": "名稱", "enterYourName": "輸入您的名稱", "introduceYourself": "簡介", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 6a745c163..d6460b3f6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -195,6 +195,42 @@ abstract class AppLocalizations { /// **'Create profile'** String get createProfile; + /// Title for the optional product analytics consent checkbox + /// + /// In en, this message translates to: + /// **'Help improve White Noise'** + String get analyticsConsentTitle; + + /// Description for the optional product analytics consent checkbox + /// + /// In en, this message translates to: + /// **'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'** + String get analyticsConsentDescription; + + /// Description for the product analytics setting row + /// + /// In en, this message translates to: + /// **'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'** + String get analyticsConsentSettingsDescription; + + /// Title for the product analytics prompt on the empty chat list + /// + /// In en, this message translates to: + /// **'Help improve White Noise?'** + String get analyticsPromptTitle; + + /// Decline button for the product analytics prompt + /// + /// In en, this message translates to: + /// **'Not now'** + String get analyticsPromptNotNow; + + /// Opt-in button for the product analytics prompt + /// + /// In en, this message translates to: + /// **'Share analytics'** + String get analyticsPromptShare; + /// Label for name input field /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index d6cb9b54b..a1672758d 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -66,6 +66,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get createProfile => 'Profil erstellen'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Name'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 1aeeb010b..7e198ac32 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -66,6 +66,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createProfile => 'Create profile'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Name'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cce686b00..d295b6825 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -66,6 +66,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get createProfile => 'Crear perfil'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Nombre'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 33faf5b5c..c1164fcdd 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -66,6 +66,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get createProfile => 'Créer le profil'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Nom'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 19174c815..68fdbc4e1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -66,6 +66,26 @@ class AppLocalizationsIt extends AppLocalizations { @override String get createProfile => 'Crea profilo'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Nome'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d274d3b55..f7e86b61b 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -66,6 +66,26 @@ class AppLocalizationsPt extends AppLocalizations { @override String get createProfile => 'Criar perfil'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Nome'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index d3092f045..8cb5e35c6 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -66,6 +66,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get createProfile => 'Создать профиль'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'Имя'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 8b8f9c4e9..489841ab9 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -66,6 +66,26 @@ class AppLocalizationsTr extends AppLocalizations { @override String get createProfile => 'Profil oluştur'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => 'İsim'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index d043fb9bb..f635fe3fd 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -66,6 +66,26 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createProfile => '创建个人资料'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => '名称'; @@ -1493,6 +1513,26 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get createProfile => '建立個人檔案'; + @override + String get analyticsConsentTitle => 'Help improve White Noise'; + + @override + String get analyticsConsentDescription => + 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + + @override + String get analyticsConsentSettingsDescription => + 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + + @override + String get analyticsPromptTitle => 'Help improve White Noise?'; + + @override + String get analyticsPromptNotNow => 'Not now'; + + @override + String get analyticsPromptShare => 'Share analytics'; + @override String get chooseName => '名稱'; diff --git a/lib/main.dart b/lib/main.dart index 8b2a2793b..e30058746 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,8 @@ import 'package:whitenoise/providers/notification_provider.dart' import 'package:whitenoise/providers/theme_provider.dart' show themeProvider; import 'package:whitenoise/routes.dart' show Routes; import 'package:whitenoise/screens/fatal_error_screen.dart'; +import 'package:whitenoise/services/product_analytics_config.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; import 'package:whitenoise/src/rust/api.dart' as rust_api; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/theme.dart'; @@ -70,11 +72,19 @@ Future initializeAppContainer() async { await _migrateDataIfNeeded(dataDir); - final config = await rust_api.createWhitenoiseConfig(dataDir: dataDir, logsDir: logsDir); + final productAnalyticsConfig = await loadProductAnalyticsConfig(); + final config = await rust_api.createWhitenoiseConfig( + dataDir: dataDir, + logsDir: logsDir, + productAnalyticsConfig: productAnalyticsConfig, + ); await rust_api.initializeWhitenoise(config: config); final container = ProviderContainer(); await container.read(authProvider.future); + final analytics = container.read(productAnalyticsServiceProvider); + await analytics.settings(); + unawaited(analytics.trackAppStarted(platform: analyticsPlatformForTarget())); return container; } @@ -114,8 +124,11 @@ class WnApp extends ConsumerStatefulWidget { ConsumerState createState() => _WnAppState(); } +enum _AnalyticsLifecycleState { foreground, background } + class _WnAppState extends ConsumerState with WidgetsBindingObserver { late final GoRouter _router; + var _analyticsLifecycleState = _AnalyticsLifecycleState.foreground; @override void initState() { @@ -146,9 +159,24 @@ class _WnAppState extends ConsumerState with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { unawaited(ref.read(foregroundServiceProvider).handleAppLifecycleChange(state)); + final analytics = ref.read(productAnalyticsServiceProvider); if (state == AppLifecycleState.resumed) { + if (_analyticsLifecycleState != _AnalyticsLifecycleState.foreground) { + _analyticsLifecycleState = _AnalyticsLifecycleState.foreground; + unawaited(analytics.trackAppForegrounded(platform: analyticsPlatformForTarget())); + } unawaited(ref.read(authProvider.notifier).ensureExternalSignersRegistered()); unawaited(_consumePendingNotificationTap()); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden || + state == AppLifecycleState.detached) { + if (_analyticsLifecycleState != _AnalyticsLifecycleState.background) { + _analyticsLifecycleState = _AnalyticsLifecycleState.background; + unawaited(() async { + await analytics.trackAppBackgrounded(platform: analyticsPlatformForTarget()); + await analytics.flush(); + }()); + } } } diff --git a/lib/providers/analytics_onboarding_provider.dart b/lib/providers/analytics_onboarding_provider.dart new file mode 100644 index 000000000..b14d081ab --- /dev/null +++ b/lib/providers/analytics_onboarding_provider.dart @@ -0,0 +1,49 @@ +import 'dart:convert' show utf8; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logging/logging.dart'; +import 'package:whitenoise/providers/auth_provider.dart'; + +const _storageKeyPrefix = 'analytics_onboarding_resolved'; + +final analyticsOnboardingResolutionServiceProvider = Provider( + (ref) { + return AnalyticsOnboardingResolutionService(ref.read(secureStorageProvider)); + }, +); + +final analyticsOnboardingResolvedProvider = FutureProvider.family((ref, pubkey) { + return ref.read(analyticsOnboardingResolutionServiceProvider).isResolved(pubkey); +}); + +class AnalyticsOnboardingResolutionService { + AnalyticsOnboardingResolutionService(this._storage); + + final FlutterSecureStorage _storage; + final _logger = Logger('AnalyticsOnboardingResolutionService'); + + Future isResolved(String pubkey) async { + try { + final value = await _storage.read(key: _storageKey(pubkey)); + return value == 'true'; + } catch (e, st) { + _logger.warning('Failed to read analytics onboarding resolution', e, st); + return false; + } + } + + Future markResolved(String pubkey) async { + try { + await _storage.write(key: _storageKey(pubkey), value: 'true'); + } catch (e, st) { + _logger.warning('Failed to persist analytics onboarding resolution', e, st); + } + } + + String _storageKey(String pubkey) { + final accountDigest = sha256.convert(utf8.encode(pubkey)).toString(); + return '${_storageKeyPrefix}_$accountDigest'; + } +} diff --git a/lib/providers/product_analytics_provider.dart b/lib/providers/product_analytics_provider.dart new file mode 100644 index 000000000..afb10182f --- /dev/null +++ b/lib/providers/product_analytics_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytics; + +class ProductAnalyticsSettingsNotifier + extends AsyncNotifier { + @override + Future build() { + return ref.read(productAnalyticsServiceProvider).settings(); + } + + Future setEnabled(bool enabled) async { + final current = state.value; + if (current != null) { + final now = DateTime.now().toUtc(); + state = AsyncData( + rust_analytics.ProductAnalyticsSettings( + enabled: enabled, + createdAt: current.createdAt, + updatedAt: now, + consentVersion: current.consentVersion, + ), + ); + } + + final settings = await ref.read(productAnalyticsServiceProvider).setAnalyticsEnabled(enabled); + state = AsyncData(settings); + } +} + +final productAnalyticsSettingsProvider = + AsyncNotifierProvider< + ProductAnalyticsSettingsNotifier, + rust_analytics.ProductAnalyticsSettings + >(ProductAnalyticsSettingsNotifier.new); diff --git a/lib/screens/chat_list_screen.dart b/lib/screens/chat_list_screen.dart index 604099458..f1f984c74 100644 --- a/lib/screens/chat_list_screen.dart +++ b/lib/screens/chat_list_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -9,8 +11,11 @@ import 'package:whitenoise/hooks/use_system_notice.dart'; import 'package:whitenoise/hooks/use_zapstore_update.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/account_pubkey_provider.dart'; +import 'package:whitenoise/providers/analytics_onboarding_provider.dart'; import 'package:whitenoise/providers/offline_provider.dart'; +import 'package:whitenoise/providers/product_analytics_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/utils/chat_search.dart'; import 'package:whitenoise/widgets/chat_list_filters.dart'; @@ -94,9 +99,13 @@ class ChatListScreen extends HookConsumerWidget { AppTypography typography, SemanticColors colors, { required bool isOffline, + required bool showAnalyticsNotice, required bool showWelcomeNotice, + required bool analyticsNoticeLoading, required String? updateVersion, required VoidCallback onUpdateDismiss, + required VoidCallback onAnalyticsDismiss, + required VoidCallback onAnalyticsShare, required VoidCallback onWelcomeDismiss, required String? noticeMessage, required WnSystemNoticeType noticeType, @@ -145,6 +154,42 @@ class ChatListScreen extends HookConsumerWidget { ); } + if (showAnalyticsNotice) { + return WnSystemNotice( + key: const Key('analytics_onboarding_notice'), + title: context.l10n.analyticsPromptTitle, + description: Text( + context.l10n.analyticsConsentSettingsDescription, + style: typography.medium14.copyWith( + color: colors.backgroundContentQuaternary, + ), + ), + type: WnSystemNoticeType.neutral, + variant: WnSystemNoticeVariant.dismissible, + actionLayout: WnSystemNoticeActionLayout.horizontal, + backgroundColor: colors.backgroundTertiary, + animateEntrance: false, + animateDismiss: false, + onDismiss: onAnalyticsDismiss, + secondaryAction: WnButton( + key: const Key('not_now_analytics_button'), + text: context.l10n.analyticsPromptNotNow, + type: WnButtonType.outline, + size: WnButtonSize.medium, + disabled: analyticsNoticeLoading, + onPressed: onAnalyticsDismiss, + ), + primaryAction: WnButton( + key: const Key('share_analytics_button'), + text: context.l10n.analyticsPromptShare, + size: WnButtonSize.medium, + loading: analyticsNoticeLoading, + disabled: analyticsNoticeLoading, + onPressed: onAnalyticsShare, + ), + ); + } + if (showWelcomeNotice) { return WnSystemNotice( key: const Key('welcome_notice'), @@ -152,6 +197,8 @@ class ChatListScreen extends HookConsumerWidget { description: _buildWelcomeDescription(context, typography, colors), type: WnSystemNoticeType.neutral, variant: WnSystemNoticeVariant.dismissible, + actionLayout: WnSystemNoticeActionLayout.horizontal, + backgroundColor: colors.backgroundTertiary, animateEntrance: false, onDismiss: onWelcomeDismiss, secondaryAction: WnButton( @@ -189,7 +236,11 @@ class ChatListScreen extends HookConsumerWidget { final updateState = useZapstoreUpdate(); final searchQuery = useState(''); final headerOpen = useState(false); + final analyticsStepResolved = useState(false); final welcomeNoticeDismissed = useState(false); + final analyticsNoticeLoading = useState(false); + final analyticsSettings = ref.watch(productAnalyticsSettingsProvider); + final analyticsOnboardingResolved = ref.watch(analyticsOnboardingResolvedProvider(pubkey)); final chatListTopPadding = useMemoized(() => ValueNotifier(safeAreaTop + _slateHeight.h)); final chatListSearch = useChatListSearch( pubkey: pubkey, @@ -198,6 +249,7 @@ class ChatListScreen extends HookConsumerWidget { ); useEffect(() { + analyticsStepResolved.value = false; welcomeNoticeDismissed.value = false; return null; }, [pubkey]); @@ -214,11 +266,55 @@ class ChatListScreen extends HookConsumerWidget { ); final isLoading = isArchiveView ? archivedChatListResult.isLoading : chatListResult.isLoading; final isEmpty = activeChatList.isEmpty && !isLoading; - final showWelcomeNotice = !isArchiveView && isEmpty && !welcomeNoticeDismissed.value; + final showEmptyOnboarding = !isArchiveView && isEmpty; + final analyticsEnabled = analyticsSettings.value?.enabled == true; + final analyticsOnboardingIsResolved = + analyticsStepResolved.value || (analyticsOnboardingResolved.value ?? false); + final showAnalyticsNotice = + showEmptyOnboarding && + analyticsOnboardingResolved.hasValue && + !analyticsOnboardingIsResolved && + analyticsSettings.hasValue && + !analyticsEnabled; + final showWelcomeNotice = + showEmptyOnboarding && + !welcomeNoticeDismissed.value && + (analyticsOnboardingIsResolved || analyticsEnabled); final activeUpdateVersion = updateState.isDismissed ? null : updateState.availableVersion; final isSearchVisible = headerOpen.value || searchQuery.value.isNotEmpty; + Future resolveAnalyticsNotice() async { + if (context.mounted) { + analyticsStepResolved.value = true; + } + await ref.read(analyticsOnboardingResolutionServiceProvider).markResolved(pubkey); + ref.invalidate(analyticsOnboardingResolvedProvider(pubkey)); + } + + void dismissWelcomeNotice() { + if (context.mounted) { + welcomeNoticeDismissed.value = true; + } + } + + Future shareAnalytics() async { + if (analyticsNoticeLoading.value) return; + analyticsNoticeLoading.value = true; + try { + await ref.read(productAnalyticsSettingsProvider.notifier).setEnabled(true); + final enabled = ref.read(productAnalyticsSettingsProvider).value?.enabled ?? false; + if (enabled) { + final analytics = ref.read(productAnalyticsServiceProvider); + await analytics.trackOnboardingStarted(); + await analytics.trackOnboardingCompleted(); + } + await resolveAnalyticsNotice(); + } finally { + analyticsNoticeLoading.value = false; + } + } + return PopScope( canPop: !isSearchVisible, onPopInvokedWithResult: (didPop, _) { @@ -312,19 +408,20 @@ class ChatListScreen extends HookConsumerWidget { context, typography, colors, + showAnalyticsNotice: showAnalyticsNotice, showWelcomeNotice: showWelcomeNotice, + analyticsNoticeLoading: analyticsNoticeLoading.value, isOffline: isOffline, updateVersion: activeUpdateVersion, onUpdateDismiss: updateState.dismiss, - onWelcomeDismiss: () { - if (context.mounted) { - welcomeNoticeDismissed.value = true; - } - }, + onAnalyticsDismiss: () => unawaited(resolveAnalyticsNotice()), + onAnalyticsShare: () => unawaited(shareAnalytics()), + onWelcomeDismiss: dismissWelcomeNotice, noticeMessage: notice.noticeMessage, noticeType: notice.noticeType, onNoticeDismiss: notice.dismissNotice, ), + systemNoticePosition: WnSlateSystemNoticePosition.belowHeader, header: const ChatListHeader(), ), ), diff --git a/lib/screens/privacy_security_screen.dart b/lib/screens/privacy_security_screen.dart index 7aefbb6f6..3fcdab0f6 100644 --- a/lib/screens/privacy_security_screen.dart +++ b/lib/screens/privacy_security_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:whitenoise/hooks/use_delete_all_data.dart'; import 'package:whitenoise/hooks/use_system_notice.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/auth_provider.dart'; +import 'package:whitenoise/providers/product_analytics_provider.dart'; import 'package:whitenoise/routes.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/widgets/wn_button.dart'; @@ -13,6 +15,7 @@ import 'package:whitenoise/widgets/wn_icon.dart'; import 'package:whitenoise/widgets/wn_slate.dart'; import 'package:whitenoise/widgets/wn_slate_navigation_header.dart'; import 'package:whitenoise/widgets/wn_system_notice.dart'; +import 'package:whitenoise/widgets/wn_toggle.dart'; class PrivacySecurityScreen extends HookConsumerWidget { const PrivacySecurityScreen({super.key}); @@ -20,9 +23,9 @@ class PrivacySecurityScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colors = context.colors; - final typography = context.typographyScaled; final (:state, :deleteAllData) = useDeleteAllData(); final systemNotice = useSystemNotice(); + final analyticsSettings = ref.watch(productAnalyticsSettingsProvider); Future handleDeleteAllData() async { final result = await WnConfirmationSlate.show( @@ -67,37 +70,29 @@ class PrivacySecurityScreen extends HookConsumerWidget { onDismiss: systemNotice.dismissNotice, ) : null, - child: Padding( - padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 14.h), + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.l10n.deleteAllAppData, - style: typography.semiBold16.copyWith( - color: colors.backgroundContentSecondary, - ), - ), - SizedBox(height: 8.h), - SizedBox( - width: double.infinity, - child: WnButton( - key: const Key('delete_all_data_button'), - text: context.l10n.deleteAppData, - onPressed: handleDeleteAllData, - type: WnButtonType.destructive, - size: WnButtonSize.medium, - loading: state.isDeleting, - disabled: state.isDeleting, - trailingIcon: WnIcons.trashCan, - ), + _AnalyticsActionBlock( + title: context.l10n.analyticsConsentTitle, + description: context.l10n.analyticsConsentSettingsDescription, + value: analyticsSettings.value?.enabled ?? false, + enabled: analyticsSettings.hasValue, + onChanged: (enabled) { + ref.read(productAnalyticsSettingsProvider.notifier).setEnabled(enabled); + }, ), - SizedBox(height: 8.h), - Text( - context.l10n.deleteAllAppDataDescription, - style: typography.medium12.copyWith( - color: colors.backgroundContentSecondary, - ), + Gap(12.h), + _DestructiveActionBlock( + title: context.l10n.deleteAllAppData, + buttonText: context.l10n.deleteAppData, + description: context.l10n.deleteAllAppDataDescription, + icon: WnIcons.trashCan, + buttonKey: const Key('delete_all_data_button'), + loading: state.isDeleting, + onPressed: state.isDeleting ? null : handleDeleteAllData, ), ], ), @@ -107,3 +102,134 @@ class PrivacySecurityScreen extends HookConsumerWidget { ); } } + +class _AnalyticsActionBlock extends StatelessWidget { + const _AnalyticsActionBlock({ + required this.title, + required this.description, + required this.value, + required this.enabled, + required this.onChanged, + }); + + final String title; + final String description; + final bool value; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ActionTextBlock( + title: title, + description: description, + descriptionGap: 0, + ), + ), + Gap(24.w), + Padding( + padding: EdgeInsets.only(top: 4.h), + child: WnToggle( + key: const Key('privacy_security_analytics_consent_toggle'), + thumbKey: const Key('privacy_security_analytics_consent_toggle_thumb'), + value: value, + enabled: enabled, + onChanged: onChanged, + ), + ), + ], + ); + } +} + +class _DestructiveActionBlock extends StatelessWidget { + const _DestructiveActionBlock({ + required this.title, + required this.buttonText, + required this.description, + required this.icon, + required this.buttonKey, + required this.loading, + required this.onPressed, + }); + + final String title; + final String buttonText; + final String description; + final WnIcons icon; + final Key buttonKey; + final bool loading; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return _ActionTextBlock( + title: title, + description: description, + descriptionGap: 4.h, + child: WnButton( + key: buttonKey, + text: buttonText, + onPressed: onPressed, + type: WnButtonType.destructive, + size: WnButtonSize.medium, + loading: loading, + disabled: loading, + trailingIcon: icon, + ), + ); + } +} + +class _ActionTextBlock extends StatelessWidget { + const _ActionTextBlock({ + required this.title, + required this.description, + required this.descriptionGap, + this.child, + }); + + final String title; + final String description; + final double descriptionGap; + final Widget? child; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final typography = context.typographyScaled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 2.w, top: 4.h, bottom: 4.h), + child: Text( + title, + style: typography.semiBold16.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + ), + if (child != null) ...[ + Gap(4.h), + SizedBox(width: double.infinity, child: child), + ], + Gap(descriptionGap), + Padding( + padding: EdgeInsets.only(left: 2.w, top: 4.h, bottom: 4.h), + child: Text( + description, + style: typography.medium14.copyWith( + color: colors.backgroundContentSecondary, + ), + ), + ), + ], + ); + } +} diff --git a/lib/services/foreground_service.dart b/lib/services/foreground_service.dart index b732c17fc..50c3acbd8 100644 --- a/lib/services/foreground_service.dart +++ b/lib/services/foreground_service.dart @@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart' show getApplicationDocumentsDi import 'package:whitenoise/services/external_signer_callback_registry.dart'; import 'package:whitenoise/services/notification_service.dart'; import 'package:whitenoise/services/notification_subscription.dart'; +import 'package:whitenoise/services/product_analytics_config.dart'; import 'package:whitenoise/src/rust/api.dart' as rust_api; import 'package:whitenoise/src/rust/api/relays.dart' as relays_api; import 'package:whitenoise/src/rust/api/utils.dart' as rust_utils; @@ -248,9 +249,11 @@ class NotificationTaskHandler extends TaskHandler { await Directory(logsDir).create(recursive: true); try { + final productAnalyticsConfig = await loadProductAnalyticsConfig(); final config = await rust_api.createWhitenoiseConfig( dataDir: dataDir, logsDir: logsDir, + productAnalyticsConfig: productAnalyticsConfig, ); await rust_api.initializeWhitenoise(config: config); _logger.info('Initialized whitenoise singleton in task isolate'); diff --git a/lib/services/product_analytics_config.dart b/lib/services/product_analytics_config.dart new file mode 100644 index 000000000..437420f94 --- /dev/null +++ b/lib/services/product_analytics_config.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kDebugMode; +import 'package:flutter/widgets.dart' show Locale, Size, WidgetsBinding; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart'; + +const _aptabaseHost = String.fromEnvironment('APTABASE_HOST'); +const _aptabaseAppKey = String.fromEnvironment('APTABASE_APP_KEY'); + +class ProductAnalyticsConfigInput { + const ProductAnalyticsConfigInput({ + required this.appVersion, + required this.bundleIdentifier, + required this.locale, + required this.platform, + required this.logicalSize, + required this.isDebug, + required this.aptabaseHost, + required this.aptabaseAppKey, + }); + + final String appVersion; + final String bundleIdentifier; + final String locale; + final TargetPlatform platform; + final Size? logicalSize; + final bool isDebug; + final String aptabaseHost; + final String aptabaseAppKey; +} + +Future loadProductAnalyticsConfig() async { + final packageInfo = await PackageInfo.fromPlatform(); + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final view = dispatcher.views.isEmpty ? null : dispatcher.views.first; + final logicalSize = view == null ? null : view.physicalSize / view.devicePixelRatio; + + return buildProductAnalyticsConfig( + ProductAnalyticsConfigInput( + appVersion: '${packageInfo.version}+${packageInfo.buildNumber}', + bundleIdentifier: packageInfo.packageName, + locale: localeToAnalyticsTag(dispatcher.locale), + platform: defaultTargetPlatform, + logicalSize: logicalSize, + isDebug: kDebugMode, + aptabaseHost: _aptabaseHost, + aptabaseAppKey: _aptabaseAppKey, + ), + ); +} + +ProductAnalyticsConfig buildProductAnalyticsConfig(ProductAnalyticsConfigInput input) { + final host = normalizedAptabaseHost(input.aptabaseHost); + final appKey = input.aptabaseAppKey.trim(); + final backend = host.isEmpty || appKey.isEmpty + ? const ProductAnalyticsBackend.disabled() + : ProductAnalyticsBackend.aptabase( + config: AptabaseAnalyticsConfig( + host: host, + appKey: appKey, + ), + ); + + return ProductAnalyticsConfig( + backend: backend, + appVersion: input.appVersion, + bundleIdentifier: input.bundleIdentifier, + deviceClass: deviceClassForPlatform( + platform: input.platform, + logicalSize: input.logicalSize, + ), + osName: osNameForPlatform(input.platform), + locale: input.locale, + isDebug: input.isDebug, + ); +} + +String normalizedAptabaseHost(String host) { + var normalized = host.trim(); + while (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; +} + +String localeToAnalyticsTag(Locale locale) { + final scriptCode = locale.scriptCode; + final countryCode = locale.countryCode; + if (scriptCode != null && scriptCode.isNotEmpty) { + return '${locale.languageCode}-$scriptCode'; + } + if (countryCode != null && countryCode.isNotEmpty) { + return '${locale.languageCode}-$countryCode'; + } + return locale.languageCode; +} + +String osNameForPlatform(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.android => 'android', + TargetPlatform.iOS => 'ios', + TargetPlatform.macOS => 'macos', + TargetPlatform.linux => 'linux', + TargetPlatform.windows => 'windows', + TargetPlatform.fuchsia => 'unknown', + }; +} + +ProductAnalyticsDeviceClass deviceClassForPlatform({ + required TargetPlatform platform, + required Size? logicalSize, +}) { + return switch (platform) { + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => ProductAnalyticsDeviceClass.desktop, + TargetPlatform.android || TargetPlatform.iOS => _mobileDeviceClass(logicalSize), + TargetPlatform.fuchsia => ProductAnalyticsDeviceClass.unknown, + }; +} + +ProductAnalyticsDeviceClass _mobileDeviceClass(Size? logicalSize) { + if (logicalSize == null || logicalSize.isEmpty) { + return ProductAnalyticsDeviceClass.unknown; + } + return logicalSize.shortestSide >= 600 + ? ProductAnalyticsDeviceClass.tablet + : ProductAnalyticsDeviceClass.phone; +} diff --git a/lib/services/product_analytics_service.dart b/lib/services/product_analytics_service.dart new file mode 100644 index 000000000..ac0745ed8 --- /dev/null +++ b/lib/services/product_analytics_service.dart @@ -0,0 +1,168 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytics; + +const _fallbackConsentVersion = 'product-analytics-v1'; + +final productAnalyticsServiceProvider = Provider((ref) { + return ProductAnalyticsService(); +}); + +typedef ProductAnalyticsSettingsReader = Future Function(); +typedef ProductAnalyticsConsentVersionReader = Future Function(); +typedef ProductAnalyticsEnabledSetter = + Future Function({ + required bool enabled, + required String consentVersion, + }); +typedef ProductAnalyticsEventTracker = + Future Function({ + required rust_analytics.ProductAnalyticsEvent event, + }); +typedef ProductAnalyticsFlusher = Future Function(); + +enum AnalyticsPlatform { + ios('ios'), + android('android'), + macos('macos'), + linux('linux'), + windows('windows'), + web('web'), + unknown('unknown') + ; + + const AnalyticsPlatform(this.value); + final String value; +} + +AnalyticsPlatform analyticsPlatformForTarget([TargetPlatform? platform]) { + if (kIsWeb) return AnalyticsPlatform.web; + return switch (platform ?? defaultTargetPlatform) { + TargetPlatform.android => AnalyticsPlatform.android, + TargetPlatform.iOS => AnalyticsPlatform.ios, + TargetPlatform.macOS => AnalyticsPlatform.macos, + TargetPlatform.linux => AnalyticsPlatform.linux, + TargetPlatform.windows => AnalyticsPlatform.windows, + TargetPlatform.fuchsia => AnalyticsPlatform.unknown, + }; +} + +class ProductAnalyticsService { + ProductAnalyticsService({ + ProductAnalyticsSettingsReader? readSettings, + ProductAnalyticsEnabledSetter? setEnabled, + ProductAnalyticsEventTracker? track, + ProductAnalyticsFlusher? flush, + ProductAnalyticsConsentVersionReader? consentVersion, + }) : _readSettings = readSettings ?? rust_analytics.productAnalyticsSettings, + _setEnabled = setEnabled ?? rust_analytics.setProductAnalyticsEnabled, + _track = track ?? rust_analytics.trackProductAnalyticsEvent, + _flush = flush ?? rust_analytics.flushProductAnalytics, + _consentVersion = consentVersion ?? rust_analytics.productAnalyticsConsentVersion; + + final ProductAnalyticsSettingsReader _readSettings; + final ProductAnalyticsEnabledSetter _setEnabled; + final ProductAnalyticsEventTracker _track; + final ProductAnalyticsFlusher _flush; + final ProductAnalyticsConsentVersionReader _consentVersion; + final _logger = Logger('ProductAnalyticsService'); + + Future settings() async { + try { + return await _readSettings(); + } catch (e, st) { + _logger.warning('Failed to read product analytics settings', e, st); + return _disabledSettings(await _consentVersionOrFallback()); + } + } + + Future setAnalyticsEnabled(bool enabled) async { + final consentVersion = await _consentVersionOrFallback(); + try { + return await _setEnabled( + enabled: enabled, + consentVersion: consentVersion, + ); + } catch (e, st) { + _logger.warning('Failed to update product analytics consent', e, st); + return _disabledSettings(consentVersion); + } + } + + Future trackAppStarted({required AnalyticsPlatform platform}) { + return _trackEvent( + rust_analytics.ProductAnalyticsEventName.appStarted, + stringProps: [_prop('platform', platform.value)], + ); + } + + Future trackAppForegrounded({required AnalyticsPlatform platform}) { + return _trackEvent( + rust_analytics.ProductAnalyticsEventName.appForegrounded, + stringProps: [_prop('platform', platform.value)], + ); + } + + Future trackAppBackgrounded({required AnalyticsPlatform platform}) { + return _trackEvent( + rust_analytics.ProductAnalyticsEventName.appBackgrounded, + stringProps: [_prop('platform', platform.value)], + ); + } + + Future trackOnboardingStarted() => + _trackEvent(rust_analytics.ProductAnalyticsEventName.onboardingStarted); + + Future trackOnboardingCompleted() => + _trackEvent(rust_analytics.ProductAnalyticsEventName.onboardingCompleted); + + Future flush() async { + try { + await _flush(); + } catch (e, st) { + _logger.warning('Failed to flush product analytics', e, st); + } + } + + Future _trackEvent( + rust_analytics.ProductAnalyticsEventName name, { + List stringProps = const [], + }) async { + try { + await _track( + event: rust_analytics.ProductAnalyticsEvent( + name: name, + stringProps: stringProps, + numberProps: const [], + ), + ); + } catch (e, st) { + _logger.fine('Product analytics event ignored: $name', e, st); + } + } + + Future _consentVersionOrFallback() async { + try { + final version = await _consentVersion(); + return version.trim().isEmpty ? _fallbackConsentVersion : version; + } catch (e, st) { + _logger.warning('Failed to read product analytics consent version', e, st); + return _fallbackConsentVersion; + } + } + + rust_analytics.ProductAnalyticsSettings _disabledSettings(String consentVersion) { + final now = DateTime.now().toUtc(); + return rust_analytics.ProductAnalyticsSettings( + enabled: false, + createdAt: now, + updatedAt: now, + consentVersion: consentVersion, + ); + } + + rust_analytics.ProductAnalyticsStringProp _prop(String key, String value) { + return rust_analytics.ProductAnalyticsStringProp(key: key, value: value); + } +} diff --git a/lib/src/rust/api.dart b/lib/src/rust/api.dart index 61033423e..add0a8214 100644 --- a/lib/src/rust/api.dart +++ b/lib/src/rust/api.dart @@ -6,6 +6,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'api/error.dart'; +import 'api/product_analytics.dart'; import 'frb_generated.dart'; // These functions are ignored because they are not marked as `pub`: `wn_session`, `wn` @@ -35,10 +36,12 @@ Future createWhitenoiseConfig({ required String dataDir, required String logsDir, List? defaultRelayUrls, + ProductAnalyticsConfig? productAnalyticsConfig, }) => RustLib.instance.api.crateApiCreateWhitenoiseConfig( dataDir: dataDir, logsDir: logsDir, defaultRelayUrls: defaultRelayUrls, + productAnalyticsConfig: productAnalyticsConfig, ); Future initializeWhitenoise({required WhitenoiseConfig config}) => @@ -84,14 +87,23 @@ class WhitenoiseConfig { /// Relay URLs used when the app needs to create default relay metadata. final List? defaultRelayUrls; + /// Optional product analytics configuration. Consent is still device-local + /// and disabled by default inside whitenoise-rs. + final ProductAnalyticsConfig? productAnalyticsConfig; + const WhitenoiseConfig({ required this.dataDir, required this.logsDir, this.defaultRelayUrls, + this.productAnalyticsConfig, }); @override - int get hashCode => dataDir.hashCode ^ logsDir.hashCode ^ defaultRelayUrls.hashCode; + int get hashCode => + dataDir.hashCode ^ + logsDir.hashCode ^ + defaultRelayUrls.hashCode ^ + productAnalyticsConfig.hashCode; @override bool operator ==(Object other) => @@ -100,5 +112,6 @@ class WhitenoiseConfig { runtimeType == other.runtimeType && dataDir == other.dataDir && logsDir == other.logsDir && - defaultRelayUrls == other.defaultRelayUrls; + defaultRelayUrls == other.defaultRelayUrls && + productAnalyticsConfig == other.productAnalyticsConfig; } diff --git a/lib/src/rust/api/groups.dart b/lib/src/rust/api/groups.dart index 2cb510309..5981a935d 100644 --- a/lib/src/rust/api/groups.dart +++ b/lib/src/rust/api/groups.dart @@ -190,6 +190,7 @@ class FlutterGroupDataUpdate { final U8Array32? imageKey; final U8Array32? imageHash; final U8Array12? imageNonce; + final BigInt? disappearingMessageSecs; const FlutterGroupDataUpdate({ this.name, @@ -199,6 +200,7 @@ class FlutterGroupDataUpdate { this.imageKey, this.imageHash, this.imageNonce, + this.disappearingMessageSecs, }); @override @@ -209,7 +211,8 @@ class FlutterGroupDataUpdate { admins.hashCode ^ imageKey.hashCode ^ imageHash.hashCode ^ - imageNonce.hashCode; + imageNonce.hashCode ^ + disappearingMessageSecs.hashCode; @override bool operator ==(Object other) => @@ -222,7 +225,8 @@ class FlutterGroupDataUpdate { admins == other.admins && imageKey == other.imageKey && imageHash == other.imageHash && - imageNonce == other.imageNonce; + imageNonce == other.imageNonce && + disappearingMessageSecs == other.disappearingMessageSecs; } class Group { diff --git a/lib/src/rust/api/media_files.dart b/lib/src/rust/api/media_files.dart index b8478a189..6bf324acb 100644 --- a/lib/src/rust/api/media_files.dart +++ b/lib/src/rust/api/media_files.dart @@ -35,17 +35,26 @@ class FileMetadata { final String? dimensions; final String? blurhash; final String? thumbhash; + final BigInt? durationMs; + final Uint8List? waveform; const FileMetadata({ this.originalFilename, this.dimensions, this.blurhash, this.thumbhash, + this.durationMs, + this.waveform, }); @override int get hashCode => - originalFilename.hashCode ^ dimensions.hashCode ^ blurhash.hashCode ^ thumbhash.hashCode; + originalFilename.hashCode ^ + dimensions.hashCode ^ + blurhash.hashCode ^ + thumbhash.hashCode ^ + durationMs.hashCode ^ + waveform.hashCode; @override bool operator ==(Object other) => @@ -55,7 +64,9 @@ class FileMetadata { originalFilename == other.originalFilename && dimensions == other.dimensions && blurhash == other.blurhash && - thumbhash == other.thumbhash; + thumbhash == other.thumbhash && + durationMs == other.durationMs && + waveform == other.waveform; } class MediaFile { diff --git a/lib/src/rust/api/messages.dart b/lib/src/rust/api/messages.dart index f8a3ccd1c..3b4d82763 100644 --- a/lib/src/rust/api/messages.dart +++ b/lib/src/rust/api/messages.dart @@ -12,7 +12,8 @@ import 'media_files.dart'; part 'messages.freezed.dart'; -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` +// These functions are ignored because they are not marked as `pub`: `line_break_token`, `nostr_token`, `push_block_tokens`, `push_inline_tokens`, `text_token`, `tokens_from_document`, `url_token`, `whitespace_token` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` Future sendMessageToGroup({ required String pubkey, diff --git a/lib/src/rust/api/product_analytics.dart b/lib/src/rust/api/product_analytics.dart new file mode 100644 index 000000000..325c1953c --- /dev/null +++ b/lib/src/rust/api/product_analytics.dart @@ -0,0 +1,254 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; + +import '../frb_generated.dart'; +import 'error.dart'; + +part 'product_analytics.freezed.dart'; + +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` + +Future productAnalyticsConsentVersion() => + RustLib.instance.api.crateApiProductAnalyticsProductAnalyticsConsentVersion(); + +Future productAnalyticsSettings() => + RustLib.instance.api.crateApiProductAnalyticsProductAnalyticsSettings(); + +Future setProductAnalyticsEnabled({ + required bool enabled, + required String consentVersion, +}) => RustLib.instance.api.crateApiProductAnalyticsSetProductAnalyticsEnabled( + enabled: enabled, + consentVersion: consentVersion, +); + +Future trackProductAnalyticsEvent({ + required ProductAnalyticsEvent event, +}) => RustLib.instance.api.crateApiProductAnalyticsTrackProductAnalyticsEvent( + event: event, +); + +Future flushProductAnalytics() => + RustLib.instance.api.crateApiProductAnalyticsFlushProductAnalytics(); + +class AptabaseAnalyticsConfig { + final String appKey; + final String host; + + const AptabaseAnalyticsConfig({ + required this.appKey, + required this.host, + }); + + @override + int get hashCode => appKey.hashCode ^ host.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AptabaseAnalyticsConfig && + runtimeType == other.runtimeType && + appKey == other.appKey && + host == other.host; +} + +@freezed +sealed class ProductAnalyticsBackend with _$ProductAnalyticsBackend { + const ProductAnalyticsBackend._(); + + const factory ProductAnalyticsBackend.disabled() = ProductAnalyticsBackend_Disabled; + const factory ProductAnalyticsBackend.aptabase({ + required AptabaseAnalyticsConfig config, + }) = ProductAnalyticsBackend_Aptabase; +} + +class ProductAnalyticsConfig { + final ProductAnalyticsBackend backend; + final String appVersion; + final String bundleIdentifier; + final ProductAnalyticsDeviceClass deviceClass; + final String osName; + final String locale; + final bool isDebug; + + const ProductAnalyticsConfig({ + required this.backend, + required this.appVersion, + required this.bundleIdentifier, + required this.deviceClass, + required this.osName, + required this.locale, + required this.isDebug, + }); + + @override + int get hashCode => + backend.hashCode ^ + appVersion.hashCode ^ + bundleIdentifier.hashCode ^ + deviceClass.hashCode ^ + osName.hashCode ^ + locale.hashCode ^ + isDebug.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductAnalyticsConfig && + runtimeType == other.runtimeType && + backend == other.backend && + appVersion == other.appVersion && + bundleIdentifier == other.bundleIdentifier && + deviceClass == other.deviceClass && + osName == other.osName && + locale == other.locale && + isDebug == other.isDebug; +} + +enum ProductAnalyticsDeviceClass { + phone, + tablet, + desktop, + unknown, +} + +class ProductAnalyticsEvent { + final ProductAnalyticsEventName name; + final List stringProps; + final List numberProps; + + const ProductAnalyticsEvent({ + required this.name, + required this.stringProps, + required this.numberProps, + }); + + @override + int get hashCode => name.hashCode ^ stringProps.hashCode ^ numberProps.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductAnalyticsEvent && + runtimeType == other.runtimeType && + name == other.name && + stringProps == other.stringProps && + numberProps == other.numberProps; +} + +enum ProductAnalyticsEventName { + analyticsEnabled, + appStarted, + appForegrounded, + appBackgrounded, + onboardingStarted, + onboardingCompleted, + identityCreated, + loginStarted, + loginCompleted, + loginFailed, + messageSendStarted, + messageSendCompleted, + messageSendFailed, + groupCreateStarted, + groupCreateCompleted, + groupCreateFailed, + membersAdded, + membersRemoved, + groupDataUpdated, + mediaUploadStarted, + mediaUploadCompleted, + mediaUploadFailed, + pushRegistrationCompleted, + pushRegistrationFailed, + settingChanged, +} + +enum ProductAnalyticsFlushStatus { + flushed, + nothingToFlush, + disabled, + unconfigured, + timedOut, +} + +class ProductAnalyticsNumberProp { + final String key; + final double value; + + const ProductAnalyticsNumberProp({ + required this.key, + required this.value, + }); + + @override + int get hashCode => key.hashCode ^ value.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductAnalyticsNumberProp && + runtimeType == other.runtimeType && + key == other.key && + value == other.value; +} + +class ProductAnalyticsSettings { + final bool enabled; + final DateTime createdAt; + final DateTime updatedAt; + final String consentVersion; + + const ProductAnalyticsSettings({ + required this.enabled, + required this.createdAt, + required this.updatedAt, + required this.consentVersion, + }); + + @override + int get hashCode => + enabled.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ consentVersion.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductAnalyticsSettings && + runtimeType == other.runtimeType && + enabled == other.enabled && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + consentVersion == other.consentVersion; +} + +class ProductAnalyticsStringProp { + final String key; + final String value; + + const ProductAnalyticsStringProp({ + required this.key, + required this.value, + }); + + @override + int get hashCode => key.hashCode ^ value.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductAnalyticsStringProp && + runtimeType == other.runtimeType && + key == other.key && + value == other.value; +} + +enum ProductAnalyticsTrackStatus { + queued, + ignoredDisabled, + ignoredUnconfigured, +} diff --git a/lib/src/rust/api/product_analytics.freezed.dart b/lib/src/rust/api/product_analytics.freezed.dart new file mode 100644 index 000000000..66e93d5e7 --- /dev/null +++ b/lib/src/rust/api/product_analytics.freezed.dart @@ -0,0 +1,272 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'product_analytics.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ProductAnalyticsBackend { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ProductAnalyticsBackend); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ProductAnalyticsBackend()'; +} + + +} + +/// @nodoc +class $ProductAnalyticsBackendCopyWith<$Res> { +$ProductAnalyticsBackendCopyWith(ProductAnalyticsBackend _, $Res Function(ProductAnalyticsBackend) __); +} + + +/// Adds pattern-matching-related methods to [ProductAnalyticsBackend]. +extension ProductAnalyticsBackendPatterns on ProductAnalyticsBackend { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( ProductAnalyticsBackend_Disabled value)? disabled,TResult Function( ProductAnalyticsBackend_Aptabase value)? aptabase,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled() when disabled != null: +return disabled(_that);case ProductAnalyticsBackend_Aptabase() when aptabase != null: +return aptabase(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( ProductAnalyticsBackend_Disabled value) disabled,required TResult Function( ProductAnalyticsBackend_Aptabase value) aptabase,}){ +final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled(): +return disabled(_that);case ProductAnalyticsBackend_Aptabase(): +return aptabase(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( ProductAnalyticsBackend_Disabled value)? disabled,TResult? Function( ProductAnalyticsBackend_Aptabase value)? aptabase,}){ +final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled() when disabled != null: +return disabled(_that);case ProductAnalyticsBackend_Aptabase() when aptabase != null: +return aptabase(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? disabled,TResult Function( AptabaseAnalyticsConfig config)? aptabase,required TResult orElse(),}) {final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled() when disabled != null: +return disabled();case ProductAnalyticsBackend_Aptabase() when aptabase != null: +return aptabase(_that.config);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() disabled,required TResult Function( AptabaseAnalyticsConfig config) aptabase,}) {final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled(): +return disabled();case ProductAnalyticsBackend_Aptabase(): +return aptabase(_that.config);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? disabled,TResult? Function( AptabaseAnalyticsConfig config)? aptabase,}) {final _that = this; +switch (_that) { +case ProductAnalyticsBackend_Disabled() when disabled != null: +return disabled();case ProductAnalyticsBackend_Aptabase() when aptabase != null: +return aptabase(_that.config);case _: + return null; + +} +} + +} + +/// @nodoc + + +class ProductAnalyticsBackend_Disabled extends ProductAnalyticsBackend { + const ProductAnalyticsBackend_Disabled(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ProductAnalyticsBackend_Disabled); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ProductAnalyticsBackend.disabled()'; +} + + +} + + + + +/// @nodoc + + +class ProductAnalyticsBackend_Aptabase extends ProductAnalyticsBackend { + const ProductAnalyticsBackend_Aptabase({required this.config}): super._(); + + + final AptabaseAnalyticsConfig config; + +/// Create a copy of ProductAnalyticsBackend +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ProductAnalyticsBackend_AptabaseCopyWith get copyWith => _$ProductAnalyticsBackend_AptabaseCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ProductAnalyticsBackend_Aptabase&&(identical(other.config, config) || other.config == config)); +} + + +@override +int get hashCode => Object.hash(runtimeType,config); + +@override +String toString() { + return 'ProductAnalyticsBackend.aptabase(config: $config)'; +} + + +} + +/// @nodoc +abstract mixin class $ProductAnalyticsBackend_AptabaseCopyWith<$Res> implements $ProductAnalyticsBackendCopyWith<$Res> { + factory $ProductAnalyticsBackend_AptabaseCopyWith(ProductAnalyticsBackend_Aptabase value, $Res Function(ProductAnalyticsBackend_Aptabase) _then) = _$ProductAnalyticsBackend_AptabaseCopyWithImpl; +@useResult +$Res call({ + AptabaseAnalyticsConfig config +}); + + + + +} +/// @nodoc +class _$ProductAnalyticsBackend_AptabaseCopyWithImpl<$Res> + implements $ProductAnalyticsBackend_AptabaseCopyWith<$Res> { + _$ProductAnalyticsBackend_AptabaseCopyWithImpl(this._self, this._then); + + final ProductAnalyticsBackend_Aptabase _self; + final $Res Function(ProductAnalyticsBackend_Aptabase) _then; + +/// Create a copy of ProductAnalyticsBackend +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? config = null,}) { + return _then(ProductAnalyticsBackend_Aptabase( +config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as AptabaseAnalyticsConfig, + )); +} + + +} + +// dart format on diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index d58625cc0..ec1d6dc71 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -24,6 +24,7 @@ import 'api/messages.dart'; import 'api/metadata.dart'; import 'api/mute_list.dart'; import 'api/notifications.dart'; +import 'api/product_analytics.dart'; import 'api/relay_defaults.dart'; import 'api/relays.dart'; import 'api/signer.dart'; @@ -90,7 +91,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1064693455; + int get rustContentHash => 2006763366; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_whitenoise', @@ -193,6 +194,7 @@ abstract class RustLibApi extends BaseApi { required String dataDir, required String logsDir, List? defaultRelayUrls, + ProductAnalyticsConfig? productAnalyticsConfig, }); Future crateApiRelaysDebugRelayControlState(); @@ -264,6 +266,8 @@ abstract class RustLibApi extends BaseApi { int? minimum, }); + Future crateApiProductAnalyticsFlushProductAnalytics(); + Future crateApiAccountsFollowUser({ required String accountPubkey, required String userToFollowPubkey, @@ -475,6 +479,10 @@ abstract class RustLibApi extends BaseApi { String crateApiUtilsNpubFromHexPubkey({required String hexPubkey}); + Future crateApiProductAnalyticsProductAnalyticsConsentVersion(); + + Future crateApiProductAnalyticsProductAnalyticsSettings(); + Future crateApiAccountsPublishAccountKeyPackage({ required String accountPubkey, }); @@ -575,6 +583,11 @@ abstract class RustLibApi extends BaseApi { PlatformInt64? pinOrder, }); + Future crateApiProductAnalyticsSetProductAnalyticsEnabled({ + required bool enabled, + required String consentVersion, + }); + Future crateApiUtilsStringFromRelayUrl({required RelayUrl relayUrl}); Stream crateApiChatListSubscribeToArchivedChatList({ @@ -611,6 +624,10 @@ abstract class RustLibApi extends BaseApi { String crateApiUtilsThemeModeToString({required ThemeMode themeMode}); + Future crateApiProductAnalyticsTrackProductAnalyticsEvent({ + required ProductAnalyticsEvent event, + }); + Future crateApiAccountGroupsUnarchiveChat({ required String accountPubkey, required String mlsGroupId, @@ -1489,6 +1506,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required String dataDir, required String logsDir, List? defaultRelayUrls, + ProductAnalyticsConfig? productAnalyticsConfig, }) { return handler.executeNormal( NormalTask( @@ -1497,6 +1515,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(dataDir, serializer); sse_encode_String(logsDir, serializer); sse_encode_opt_list_String(defaultRelayUrls, serializer); + sse_encode_opt_box_autoadd_product_analytics_config( + productAnalyticsConfig, + serializer, + ); pdeCallFfi( generalizedFrbRustBinding, serializer, @@ -1509,7 +1531,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: null, ), constMeta: kCrateApiCreateWhitenoiseConfigConstMeta, - argValues: [dataDir, logsDir, defaultRelayUrls], + argValues: [dataDir, logsDir, defaultRelayUrls, productAnalyticsConfig], apiImpl: this, ), ); @@ -1517,7 +1539,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCreateWhitenoiseConfigConstMeta => const TaskConstMeta( debugName: 'create_whitenoise_config', - argNames: ['dataDir', 'logsDir', 'defaultRelayUrls'], + argNames: [ + 'dataDir', + 'logsDir', + 'defaultRelayUrls', + 'productAnalyticsConfig', + ], ); @override @@ -2066,6 +2093,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['pubkey', 'groupId', 'minimum'], ); + @override + Future crateApiProductAnalyticsFlushProductAnalytics() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 39, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_product_analytics_flush_status, + decodeErrorData: sse_decode_api_error, + ), + constMeta: kCrateApiProductAnalyticsFlushProductAnalyticsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiProductAnalyticsFlushProductAnalyticsConstMeta => const TaskConstMeta( + debugName: 'flush_product_analytics', + argNames: [], + ); + @override Future crateApiAccountsFollowUser({ required String accountPubkey, @@ -2080,7 +2136,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 40, port: port_, ); }, @@ -2110,7 +2166,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 41, port: port_, ); }, @@ -2144,7 +2200,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 42, port: port_, ); }, @@ -2173,7 +2229,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -2202,7 +2258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 44, port: port_, ); }, @@ -2235,7 +2291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 45, port: port_, ); }, @@ -2267,7 +2323,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 46, port: port_, ); }, @@ -2301,7 +2357,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 47, port: port_, ); }, @@ -2330,7 +2386,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 48, port: port_, ); }, @@ -2364,7 +2420,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 49, port: port_, ); }, @@ -2398,7 +2454,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 50, port: port_, ); }, @@ -2432,7 +2488,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 51, port: port_, ); }, @@ -2466,7 +2522,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 52, port: port_, ); }, @@ -2500,7 +2556,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 53, port: port_, ); }, @@ -2532,7 +2588,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 54, port: port_, ); }, @@ -2566,7 +2622,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 55, port: port_, ); }, @@ -2600,7 +2656,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 56, port: port_, ); }, @@ -2634,7 +2690,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 57, port: port_, ); }, @@ -2668,7 +2724,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 58, port: port_, ); }, @@ -2698,7 +2754,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 59, port: port_, ); }, @@ -2732,7 +2788,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 60, port: port_, ); }, @@ -2766,7 +2822,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 61, port: port_, ); }, @@ -2800,7 +2856,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 62, port: port_, ); }, @@ -2834,7 +2890,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 63, port: port_, ); }, @@ -2868,7 +2924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 64, port: port_, ); }, @@ -2907,7 +2963,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 65, port: port_, ); }, @@ -2934,7 +2990,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(npub, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 65)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 66)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2964,7 +3020,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 67, port: port_, ); }, @@ -2998,7 +3054,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 68, port: port_, ); }, @@ -3032,7 +3088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 69, port: port_, ); }, @@ -3058,7 +3114,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; }, codec: SseCodec( decodeSuccessData: @@ -3083,7 +3139,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 71)!; }, codec: SseCodec( decodeSuccessData: @@ -3108,7 +3164,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 71)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 72)!; }, codec: SseCodec( decodeSuccessData: @@ -3133,7 +3189,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 72)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 73)!; }, codec: SseCodec( decodeSuccessData: @@ -3158,7 +3214,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 73)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 74)!; }, codec: SseCodec( decodeSuccessData: @@ -3183,7 +3239,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 74)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 75)!; }, codec: SseCodec( decodeSuccessData: @@ -3208,7 +3264,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 75)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 76)!; }, codec: SseCodec( decodeSuccessData: @@ -3233,7 +3289,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 76)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 77)!; }, codec: SseCodec( decodeSuccessData: @@ -3258,7 +3314,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 77)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 78)!; }, codec: SseCodec( decodeSuccessData: @@ -3283,7 +3339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 78)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 79)!; }, codec: SseCodec( decodeSuccessData: @@ -3312,7 +3368,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { language, serializer, ); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 79)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 80)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -3336,7 +3392,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 80)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 81)!; }, codec: SseCodec( decodeSuccessData: @@ -3369,7 +3425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 82, port: port_, ); }, @@ -3403,7 +3459,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 83, port: port_, ); }, @@ -3437,7 +3493,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 84, port: port_, ); }, @@ -3467,7 +3523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 85, port: port_, ); }, @@ -3499,7 +3555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 86, port: port_, ); }, @@ -3557,7 +3613,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 86, + funcId: 87, port: port_, ); }, @@ -3605,7 +3661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 87, + funcId: 88, port: port_, ); }, @@ -3638,7 +3694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 88, + funcId: 89, port: port_, ); }, @@ -3670,7 +3726,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 89, + funcId: 90, port: port_, ); }, @@ -3704,7 +3760,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 90, + funcId: 91, port: port_, ); }, @@ -3734,7 +3790,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 91, + funcId: 92, port: port_, ); }, @@ -3768,7 +3824,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 92, + funcId: 93, port: port_, ); }, @@ -3804,7 +3860,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 93, + funcId: 94, port: port_, ); }, @@ -3831,7 +3887,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(hexPubkey, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 94)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 95)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -3849,6 +3905,66 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['hexPubkey'], ); + @override + Future crateApiProductAnalyticsProductAnalyticsConsentVersion() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 96, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiProductAnalyticsProductAnalyticsConsentVersionConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiProductAnalyticsProductAnalyticsConsentVersionConstMeta => + const TaskConstMeta( + debugName: 'product_analytics_consent_version', + argNames: [], + ); + + @override + Future crateApiProductAnalyticsProductAnalyticsSettings() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 97, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_product_analytics_settings, + decodeErrorData: sse_decode_api_error, + ), + constMeta: kCrateApiProductAnalyticsProductAnalyticsSettingsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiProductAnalyticsProductAnalyticsSettingsConstMeta => + const TaskConstMeta( + debugName: 'product_analytics_settings', + argNames: [], + ); + @override Future crateApiAccountsPublishAccountKeyPackage({ required String accountPubkey, @@ -3861,7 +3977,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 98, port: port_, ); }, @@ -3918,7 +4034,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 96, + funcId: 99, port: port_, ); }, @@ -3961,7 +4077,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 100, port: port_, ); }, @@ -3991,7 +4107,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 101, port: port_, ); }, @@ -4021,7 +4137,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 102, port: port_, ); }, @@ -4052,7 +4168,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 103, port: port_, ); }, @@ -4092,7 +4208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 104, port: port_, ); }, @@ -4128,7 +4244,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 105, port: port_, ); }, @@ -4158,7 +4274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 106, port: port_, ); }, @@ -4194,7 +4310,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 107, port: port_, ); }, @@ -4234,7 +4350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 108, port: port_, ); }, @@ -4270,7 +4386,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 109, port: port_, ); }, @@ -4308,7 +4424,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 110, port: port_, ); }, @@ -4349,7 +4465,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 111, port: port_, ); }, @@ -4391,7 +4507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 112, port: port_, ); }, @@ -4439,7 +4555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 113, port: port_, ); }, @@ -4502,7 +4618,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 114, port: port_, ); }, @@ -4538,7 +4654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 115, port: port_, ); }, @@ -4558,6 +4674,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['accountPubkey', 'mlsGroupId', 'pinOrder'], ); + @override + Future crateApiProductAnalyticsSetProductAnalyticsEnabled({ + required bool enabled, + required String consentVersion, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(enabled, serializer); + sse_encode_String(consentVersion, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 116, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_product_analytics_settings, + decodeErrorData: sse_decode_api_error, + ), + constMeta: kCrateApiProductAnalyticsSetProductAnalyticsEnabledConstMeta, + argValues: [enabled, consentVersion], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiProductAnalyticsSetProductAnalyticsEnabledConstMeta => + const TaskConstMeta( + debugName: 'set_product_analytics_enabled', + argNames: ['enabled', 'consentVersion'], + ); + @override Future crateApiUtilsStringFromRelayUrl({required RelayUrl relayUrl}) { return handler.executeNormal( @@ -4571,7 +4722,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 117, port: port_, ); }, @@ -4606,7 +4757,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 118, port: port_, ); }, @@ -4643,7 +4794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 119, port: port_, ); }, @@ -4682,7 +4833,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 120, port: port_, ); }, @@ -4721,7 +4872,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 121, port: port_, ); }, @@ -4755,7 +4906,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 118, + funcId: 122, port: port_, ); }, @@ -4792,7 +4943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 119, + funcId: 123, port: port_, ); }, @@ -4829,7 +4980,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 120, + funcId: 124, port: port_, ); }, @@ -4861,7 +5012,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 121, + funcId: 125, port: port_, ); }, @@ -4891,7 +5042,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 122, + funcId: 126, )!; }, codec: SseCodec( @@ -4920,7 +5071,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 123, + funcId: 127, )!; }, codec: SseCodec( @@ -4949,7 +5100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 124, + funcId: 128, )!; }, codec: SseCodec( @@ -4982,7 +5133,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 125, + funcId: 129, )!; }, codec: SseCodec( @@ -5002,9 +5153,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiAccountGroupsUnarchiveChat({ - required String accountPubkey, - required String mlsGroupId, + Future crateApiProductAnalyticsTrackProductAnalyticsEvent({ + required ProductAnalyticsEvent event, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_product_analytics_event(event, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 130, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_product_analytics_track_status, + decodeErrorData: sse_decode_api_error, + ), + constMeta: kCrateApiProductAnalyticsTrackProductAnalyticsEventConstMeta, + argValues: [event], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiProductAnalyticsTrackProductAnalyticsEventConstMeta => + const TaskConstMeta( + debugName: 'track_product_analytics_event', + argNames: ['event'], + ); + + @override + Future crateApiAccountGroupsUnarchiveChat({ + required String accountPubkey, + required String mlsGroupId, }) { return handler.executeNormal( NormalTask( @@ -5015,7 +5199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 126, + funcId: 131, port: port_, ); }, @@ -5049,7 +5233,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 127, + funcId: 132, port: port_, ); }, @@ -5083,7 +5267,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 128, + funcId: 133, port: port_, ); }, @@ -5117,7 +5301,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 129, + funcId: 134, port: port_, ); }, @@ -5151,7 +5335,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 130, + funcId: 135, port: port_, ); }, @@ -5184,7 +5368,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 131, + funcId: 136, port: port_, ); }, @@ -5218,7 +5402,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 132, + funcId: 137, port: port_, ); }, @@ -5251,7 +5435,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 133, + funcId: 138, port: port_, ); }, @@ -5289,7 +5473,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 134, + funcId: 139, port: port_, ); }, @@ -5325,7 +5509,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 135, + funcId: 140, port: port_, ); }, @@ -5363,7 +5547,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 136, + funcId: 141, port: port_, ); }, @@ -5403,7 +5587,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 137, + funcId: 142, port: port_, ); }, @@ -5443,7 +5627,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 138, + funcId: 143, port: port_, ); }, @@ -5477,7 +5661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 139, + funcId: 144, port: port_, ); }, @@ -5516,7 +5700,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 140, + funcId: 145, port: port_, ); }, @@ -5548,7 +5732,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 141, + funcId: 146, port: port_, ); }, @@ -6097,6 +6281,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + AptabaseAnalyticsConfig dco_decode_aptabase_analytics_config(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return AptabaseAnalyticsConfig( + appKey: dco_decode_String(arr[0]), + host: dco_decode_String(arr[1]), + ); + } + @protected bool dco_decode_bool(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6115,6 +6310,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_api_error(raw); } + @protected + AptabaseAnalyticsConfig dco_decode_box_autoadd_aptabase_analytics_config( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_aptabase_analytics_config(raw); + } + @protected bool dco_decode_box_autoadd_bool(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6201,6 +6404,22 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_message_update(raw); } + @protected + ProductAnalyticsConfig dco_decode_box_autoadd_product_analytics_config( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_product_analytics_config(raw); + } + + @protected + ProductAnalyticsEvent dco_decode_box_autoadd_product_analytics_event( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_product_analytics_event(raw); + } + @protected PushRegistration dco_decode_box_autoadd_push_registration(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6213,6 +6432,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as int; } + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_u_64(raw); + } + @protected User dco_decode_box_autoadd_user(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6399,16 +6624,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + double dco_decode_f_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as double; + } + @protected FileMetadata dco_decode_file_metadata(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + if (arr.length != 6) throw Exception('unexpected arr length: expect 6 but see ${arr.length}'); return FileMetadata( originalFilename: dco_decode_opt_String(arr[0]), dimensions: dco_decode_opt_String(arr[1]), blurhash: dco_decode_opt_String(arr[2]), thumbhash: dco_decode_opt_String(arr[3]), + durationMs: dco_decode_opt_box_autoadd_u_64(arr[4]), + waveform: dco_decode_opt_list_prim_u_8_strict(arr[5]), ); } @@ -6431,7 +6664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { FlutterGroupDataUpdate dco_decode_flutter_group_data_update(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 7) throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + if (arr.length != 8) throw Exception('unexpected arr length: expect 8 but see ${arr.length}'); return FlutterGroupDataUpdate( name: dco_decode_opt_String(arr[0]), description: dco_decode_opt_String(arr[1]), @@ -6440,6 +6673,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { imageKey: dco_decode_opt_u_8_array_32(arr[4]), imageHash: dco_decode_opt_u_8_array_32(arr[5]), imageNonce: dco_decode_opt_u_8_array_12(arr[6]), + disappearingMessageSecs: dco_decode_opt_box_autoadd_u_64(arr[7]), ); } @@ -6697,6 +6931,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as Uint8List; } + @protected + List dco_decode_list_product_analytics_number_prop(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_product_analytics_number_prop).toList(); + } + + @protected + List dco_decode_list_product_analytics_string_prop(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_product_analytics_string_prop).toList(); + } + @protected List<(String, String)> dco_decode_list_record_string_string(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6950,6 +7196,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_box_autoadd_i_64(raw); } + @protected + ProductAnalyticsConfig? dco_decode_opt_box_autoadd_product_analytics_config( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_product_analytics_config(raw); + } + @protected PushRegistration? dco_decode_opt_box_autoadd_push_registration(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6962,6 +7216,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_box_autoadd_u_32(raw); } + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_u_64(raw); + } + @protected List? dco_decode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( @@ -6981,6 +7241,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_list_String(raw); } + @protected + Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_list_prim_u_8_strict(raw); + } + @protected U8Array12? dco_decode_opt_u_8_array_12(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6993,6 +7259,120 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_u_8_array_32(raw); } + @protected + ProductAnalyticsBackend dco_decode_product_analytics_backend(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return const ProductAnalyticsBackend_Disabled(); + case 1: + return ProductAnalyticsBackend_Aptabase( + config: dco_decode_box_autoadd_aptabase_analytics_config(raw[1]), + ); + default: + throw Exception('unreachable'); + } + } + + @protected + ProductAnalyticsConfig dco_decode_product_analytics_config(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 7) throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + return ProductAnalyticsConfig( + backend: dco_decode_product_analytics_backend(arr[0]), + appVersion: dco_decode_String(arr[1]), + bundleIdentifier: dco_decode_String(arr[2]), + deviceClass: dco_decode_product_analytics_device_class(arr[3]), + osName: dco_decode_String(arr[4]), + locale: dco_decode_String(arr[5]), + isDebug: dco_decode_bool(arr[6]), + ); + } + + @protected + ProductAnalyticsDeviceClass dco_decode_product_analytics_device_class( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ProductAnalyticsDeviceClass.values[raw as int]; + } + + @protected + ProductAnalyticsEvent dco_decode_product_analytics_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return ProductAnalyticsEvent( + name: dco_decode_product_analytics_event_name(arr[0]), + stringProps: dco_decode_list_product_analytics_string_prop(arr[1]), + numberProps: dco_decode_list_product_analytics_number_prop(arr[2]), + ); + } + + @protected + ProductAnalyticsEventName dco_decode_product_analytics_event_name( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ProductAnalyticsEventName.values[raw as int]; + } + + @protected + ProductAnalyticsFlushStatus dco_decode_product_analytics_flush_status( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ProductAnalyticsFlushStatus.values[raw as int]; + } + + @protected + ProductAnalyticsNumberProp dco_decode_product_analytics_number_prop( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return ProductAnalyticsNumberProp( + key: dco_decode_String(arr[0]), + value: dco_decode_f_64(arr[1]), + ); + } + + @protected + ProductAnalyticsSettings dco_decode_product_analytics_settings(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return ProductAnalyticsSettings( + enabled: dco_decode_bool(arr[0]), + createdAt: dco_decode_Chrono_Utc(arr[1]), + updatedAt: dco_decode_Chrono_Utc(arr[2]), + consentVersion: dco_decode_String(arr[3]), + ); + } + + @protected + ProductAnalyticsStringProp dco_decode_product_analytics_string_prop( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return ProductAnalyticsStringProp( + key: dco_decode_String(arr[0]), + value: dco_decode_String(arr[1]), + ); + } + + @protected + ProductAnalyticsTrackStatus dco_decode_product_analytics_track_status( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ProductAnalyticsTrackStatus.values[raw as int]; + } + @protected PushPlatform dco_decode_push_platform(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -7284,11 +7664,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { WhitenoiseConfig dco_decode_whitenoise_config(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return WhitenoiseConfig( dataDir: dco_decode_String(arr[0]), logsDir: dco_decode_String(arr[1]), defaultRelayUrls: dco_decode_opt_list_String(arr[2]), + productAnalyticsConfig: dco_decode_opt_box_autoadd_product_analytics_config(arr[3]), ); } @@ -7761,6 +8142,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + AptabaseAnalyticsConfig sse_decode_aptabase_analytics_config( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_appKey = sse_decode_String(deserializer); + final var_host = sse_decode_String(deserializer); + return AptabaseAnalyticsConfig(appKey: var_appKey, host: var_host); + } + @protected bool sse_decode_bool(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -7779,6 +8170,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_api_error(deserializer)); } + @protected + AptabaseAnalyticsConfig sse_decode_box_autoadd_aptabase_analytics_config( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_aptabase_analytics_config(deserializer)); + } + @protected bool sse_decode_box_autoadd_bool(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -7883,6 +8282,22 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_message_update(deserializer)); } + @protected + ProductAnalyticsConfig sse_decode_box_autoadd_product_analytics_config( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_product_analytics_config(deserializer)); + } + + @protected + ProductAnalyticsEvent sse_decode_box_autoadd_product_analytics_event( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_product_analytics_event(deserializer)); + } + @protected PushRegistration sse_decode_box_autoadd_push_registration( SseDeserializer deserializer, @@ -7897,6 +8312,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_u_32(deserializer)); } + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_u_64(deserializer)); + } + @protected User sse_decode_box_autoadd_user(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8127,6 +8548,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return EmojiReaction(emoji: var_emoji, count: var_count, users: var_users); } + @protected + double sse_decode_f_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getFloat64(); + } + @protected FileMetadata sse_decode_file_metadata(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8134,11 +8561,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final var_dimensions = sse_decode_opt_String(deserializer); final var_blurhash = sse_decode_opt_String(deserializer); final var_thumbhash = sse_decode_opt_String(deserializer); + final var_durationMs = sse_decode_opt_box_autoadd_u_64(deserializer); + final var_waveform = sse_decode_opt_list_prim_u_8_strict(deserializer); return FileMetadata( originalFilename: var_originalFilename, dimensions: var_dimensions, blurhash: var_blurhash, thumbhash: var_thumbhash, + durationMs: var_durationMs, + waveform: var_waveform, ); } @@ -8173,6 +8604,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final var_imageKey = sse_decode_opt_u_8_array_32(deserializer); final var_imageHash = sse_decode_opt_u_8_array_32(deserializer); final var_imageNonce = sse_decode_opt_u_8_array_12(deserializer); + final var_disappearingMessageSecs = sse_decode_opt_box_autoadd_u_64( + deserializer, + ); return FlutterGroupDataUpdate( name: var_name, description: var_description, @@ -8181,6 +8615,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { imageKey: var_imageKey, imageHash: var_imageHash, imageNonce: var_imageNonce, + disappearingMessageSecs: var_disappearingMessageSecs, ); } @@ -8582,6 +9017,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8List(len_); } + @protected + List sse_decode_list_product_analytics_number_prop( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + final len_ = sse_decode_i_32(deserializer); + final ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_product_analytics_number_prop(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_product_analytics_string_prop( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + final len_ = sse_decode_i_32(deserializer); + final ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_product_analytics_string_prop(deserializer)); + } + return ans_; + } + @protected List<(String, String)> sse_decode_list_record_string_string( SseDeserializer deserializer, @@ -8989,31 +9452,55 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } @protected - PushRegistration? sse_decode_opt_box_autoadd_push_registration( + ProductAnalyticsConfig? sse_decode_opt_box_autoadd_product_analytics_config( SseDeserializer deserializer, ) { // Codec=Sse (Serialization based), see doc to use other codecs if (sse_decode_bool(deserializer)) { - return (sse_decode_box_autoadd_push_registration(deserializer)); + return (sse_decode_box_autoadd_product_analytics_config(deserializer)); } else { return null; } } @protected - int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer) { + PushRegistration? sse_decode_opt_box_autoadd_push_registration( + SseDeserializer deserializer, + ) { // Codec=Sse (Serialization based), see doc to use other codecs if (sse_decode_bool(deserializer)) { - return (sse_decode_box_autoadd_u_32(deserializer)); + return (sse_decode_box_autoadd_push_registration(deserializer)); } else { return null; } } @protected - List? + int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_u_32(deserializer)); + } else { + return null; + } + } + + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_u_64(deserializer)); + } else { + return null; + } + } + + @protected + List? sse_decode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( SseDeserializer deserializer, ) { @@ -9039,6 +9526,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_list_prim_u_8_strict(deserializer)); + } else { + return null; + } + } + @protected U8Array12? sse_decode_opt_u_8_array_12(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9061,6 +9559,143 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + ProductAnalyticsBackend sse_decode_product_analytics_backend( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + final tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return const ProductAnalyticsBackend_Disabled(); + case 1: + final var_config = sse_decode_box_autoadd_aptabase_analytics_config( + deserializer, + ); + return ProductAnalyticsBackend_Aptabase(config: var_config); + default: + throw UnimplementedError(''); + } + } + + @protected + ProductAnalyticsConfig sse_decode_product_analytics_config( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_backend = sse_decode_product_analytics_backend(deserializer); + final var_appVersion = sse_decode_String(deserializer); + final var_bundleIdentifier = sse_decode_String(deserializer); + final var_deviceClass = sse_decode_product_analytics_device_class( + deserializer, + ); + final var_osName = sse_decode_String(deserializer); + final var_locale = sse_decode_String(deserializer); + final var_isDebug = sse_decode_bool(deserializer); + return ProductAnalyticsConfig( + backend: var_backend, + appVersion: var_appVersion, + bundleIdentifier: var_bundleIdentifier, + deviceClass: var_deviceClass, + osName: var_osName, + locale: var_locale, + isDebug: var_isDebug, + ); + } + + @protected + ProductAnalyticsDeviceClass sse_decode_product_analytics_device_class( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_i_32(deserializer); + return ProductAnalyticsDeviceClass.values[inner]; + } + + @protected + ProductAnalyticsEvent sse_decode_product_analytics_event( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_name = sse_decode_product_analytics_event_name(deserializer); + final var_stringProps = sse_decode_list_product_analytics_string_prop( + deserializer, + ); + final var_numberProps = sse_decode_list_product_analytics_number_prop( + deserializer, + ); + return ProductAnalyticsEvent( + name: var_name, + stringProps: var_stringProps, + numberProps: var_numberProps, + ); + } + + @protected + ProductAnalyticsEventName sse_decode_product_analytics_event_name( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_i_32(deserializer); + return ProductAnalyticsEventName.values[inner]; + } + + @protected + ProductAnalyticsFlushStatus sse_decode_product_analytics_flush_status( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_i_32(deserializer); + return ProductAnalyticsFlushStatus.values[inner]; + } + + @protected + ProductAnalyticsNumberProp sse_decode_product_analytics_number_prop( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_key = sse_decode_String(deserializer); + final var_value = sse_decode_f_64(deserializer); + return ProductAnalyticsNumberProp(key: var_key, value: var_value); + } + + @protected + ProductAnalyticsSettings sse_decode_product_analytics_settings( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_enabled = sse_decode_bool(deserializer); + final var_createdAt = sse_decode_Chrono_Utc(deserializer); + final var_updatedAt = sse_decode_Chrono_Utc(deserializer); + final var_consentVersion = sse_decode_String(deserializer); + return ProductAnalyticsSettings( + enabled: var_enabled, + createdAt: var_createdAt, + updatedAt: var_updatedAt, + consentVersion: var_consentVersion, + ); + } + + @protected + ProductAnalyticsStringProp sse_decode_product_analytics_string_prop( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_key = sse_decode_String(deserializer); + final var_value = sse_decode_String(deserializer); + return ProductAnalyticsStringProp(key: var_key, value: var_value); + } + + @protected + ProductAnalyticsTrackStatus sse_decode_product_analytics_track_status( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_i_32(deserializer); + return ProductAnalyticsTrackStatus.values[inner]; + } + @protected PushPlatform sse_decode_push_platform(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9383,10 +10018,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final var_dataDir = sse_decode_String(deserializer); final var_logsDir = sse_decode_String(deserializer); final var_defaultRelayUrls = sse_decode_opt_list_String(deserializer); + final var_productAnalyticsConfig = sse_decode_opt_box_autoadd_product_analytics_config( + deserializer, + ); return WhitenoiseConfig( dataDir: var_dataDir, logsDir: var_logsDir, defaultRelayUrls: var_defaultRelayUrls, + productAnalyticsConfig: var_productAnalyticsConfig, ); } @@ -9941,6 +10580,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_aptabase_analytics_config( + AptabaseAnalyticsConfig self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.appKey, serializer); + sse_encode_String(self.host, serializer); + } + @protected void sse_encode_bool(bool self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9965,6 +10614,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_api_error(self, serializer); } + @protected + void sse_encode_box_autoadd_aptabase_analytics_config( + AptabaseAnalyticsConfig self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_aptabase_analytics_config(self, serializer); + } + @protected void sse_encode_box_autoadd_bool(bool self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -10082,6 +10740,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_message_update(self, serializer); } + @protected + void sse_encode_box_autoadd_product_analytics_config( + ProductAnalyticsConfig self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_product_analytics_config(self, serializer); + } + + @protected + void sse_encode_box_autoadd_product_analytics_event( + ProductAnalyticsEvent self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_product_analytics_event(self, serializer); + } + @protected void sse_encode_box_autoadd_push_registration( PushRegistration self, @@ -10097,6 +10773,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(self, serializer); } + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_64(self, serializer); + } + @protected void sse_encode_box_autoadd_user(User self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -10275,6 +10957,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_String(self.users, serializer); } + @protected + void sse_encode_f_64(double self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putFloat64(self); + } + @protected void sse_encode_file_metadata(FileMetadata self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -10282,6 +10970,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(self.dimensions, serializer); sse_encode_opt_String(self.blurhash, serializer); sse_encode_opt_String(self.thumbhash, serializer); + sse_encode_opt_box_autoadd_u_64(self.durationMs, serializer); + sse_encode_opt_list_prim_u_8_strict(self.waveform, serializer); } @protected @@ -10308,6 +10998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_u_8_array_32(self.imageKey, serializer); sse_encode_opt_u_8_array_32(self.imageHash, serializer); sse_encode_opt_u_8_array_12(self.imageNonce, serializer); + sse_encode_opt_box_autoadd_u_64(self.disappearingMessageSecs, serializer); } @protected @@ -10643,6 +11334,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8List(self); } + @protected + void sse_encode_list_product_analytics_number_prop( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_product_analytics_number_prop(item, serializer); + } + } + + @protected + void sse_encode_list_product_analytics_string_prop( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_product_analytics_string_prop(item, serializer); + } + } + @protected void sse_encode_list_record_string_string( List<(String, String)> self, @@ -10983,6 +11698,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_product_analytics_config( + ProductAnalyticsConfig? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_product_analytics_config(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_push_registration( PushRegistration? self, @@ -11006,6 +11734,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_u_64(self, serializer); + } + } + @protected void sse_encode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( @@ -11036,6 +11774,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_list_prim_u_8_strict( + Uint8List? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_list_prim_u_8_strict(self, serializer); + } + } + @protected void sse_encode_opt_u_8_array_12(U8Array12? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -11056,6 +11807,115 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_product_analytics_backend( + ProductAnalyticsBackend self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case ProductAnalyticsBackend_Disabled(): + sse_encode_i_32(0, serializer); + case ProductAnalyticsBackend_Aptabase(config: final config): + sse_encode_i_32(1, serializer); + sse_encode_box_autoadd_aptabase_analytics_config(config, serializer); + } + } + + @protected + void sse_encode_product_analytics_config( + ProductAnalyticsConfig self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_product_analytics_backend(self.backend, serializer); + sse_encode_String(self.appVersion, serializer); + sse_encode_String(self.bundleIdentifier, serializer); + sse_encode_product_analytics_device_class(self.deviceClass, serializer); + sse_encode_String(self.osName, serializer); + sse_encode_String(self.locale, serializer); + sse_encode_bool(self.isDebug, serializer); + } + + @protected + void sse_encode_product_analytics_device_class( + ProductAnalyticsDeviceClass self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_product_analytics_event( + ProductAnalyticsEvent self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_product_analytics_event_name(self.name, serializer); + sse_encode_list_product_analytics_string_prop(self.stringProps, serializer); + sse_encode_list_product_analytics_number_prop(self.numberProps, serializer); + } + + @protected + void sse_encode_product_analytics_event_name( + ProductAnalyticsEventName self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_product_analytics_flush_status( + ProductAnalyticsFlushStatus self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_product_analytics_number_prop( + ProductAnalyticsNumberProp self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.key, serializer); + sse_encode_f_64(self.value, serializer); + } + + @protected + void sse_encode_product_analytics_settings( + ProductAnalyticsSettings self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bool(self.enabled, serializer); + sse_encode_Chrono_Utc(self.createdAt, serializer); + sse_encode_Chrono_Utc(self.updatedAt, serializer); + sse_encode_String(self.consentVersion, serializer); + } + + @protected + void sse_encode_product_analytics_string_prop( + ProductAnalyticsStringProp self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.key, serializer); + sse_encode_String(self.value, serializer); + } + + @protected + void sse_encode_product_analytics_track_status( + ProductAnalyticsTrackStatus self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + @protected void sse_encode_push_platform(PushPlatform self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -11328,6 +12188,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.dataDir, serializer); sse_encode_String(self.logsDir, serializer); sse_encode_opt_list_String(self.defaultRelayUrls, serializer); + sse_encode_opt_box_autoadd_product_analytics_config( + self.productAnalyticsConfig, + serializer, + ); } } diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index e84d5b2cb..20fd26999 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -25,6 +25,7 @@ import 'api/messages.dart'; import 'api/metadata.dart'; import 'api/mute_list.dart'; import 'api/notifications.dart'; +import 'api/product_analytics.dart'; import 'api/relay_defaults.dart'; import 'api/relays.dart'; import 'api/signer.dart'; @@ -265,6 +266,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ApiError dco_decode_api_error(dynamic raw); + @protected + AptabaseAnalyticsConfig dco_decode_aptabase_analytics_config(dynamic raw); + @protected bool dco_decode_bool(dynamic raw); @@ -274,6 +278,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ApiError dco_decode_box_autoadd_api_error(dynamic raw); + @protected + AptabaseAnalyticsConfig dco_decode_box_autoadd_aptabase_analytics_config( + dynamic raw, + ); + @protected bool dco_decode_box_autoadd_bool(dynamic raw); @@ -318,12 +327,25 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MessageUpdate dco_decode_box_autoadd_message_update(dynamic raw); + @protected + ProductAnalyticsConfig dco_decode_box_autoadd_product_analytics_config( + dynamic raw, + ); + + @protected + ProductAnalyticsEvent dco_decode_box_autoadd_product_analytics_event( + dynamic raw, + ); + @protected PushRegistration dco_decode_box_autoadd_push_registration(dynamic raw); @protected int dco_decode_box_autoadd_u_32(dynamic raw); + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw); + @protected User dco_decode_box_autoadd_user(dynamic raw); @@ -363,6 +385,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected EmojiReaction dco_decode_emoji_reaction(dynamic raw); + @protected + double dco_decode_f_64(dynamic raw); + @protected FileMetadata dco_decode_file_metadata(dynamic raw); @@ -473,6 +498,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + List dco_decode_list_product_analytics_number_prop(dynamic raw); + + @protected + List dco_decode_list_product_analytics_string_prop(dynamic raw); + @protected List<(String, String)> dco_decode_list_record_string_string(dynamic raw); @@ -565,12 +596,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw); + @protected + ProductAnalyticsConfig? dco_decode_opt_box_autoadd_product_analytics_config( + dynamic raw, + ); + @protected PushRegistration? dco_decode_opt_box_autoadd_push_registration(dynamic raw); @protected int? dco_decode_opt_box_autoadd_u_32(dynamic raw); + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); + @protected List? dco_decode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( @@ -580,12 +619,57 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List? dco_decode_opt_list_String(dynamic raw); + @protected + Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); + @protected U8Array12? dco_decode_opt_u_8_array_12(dynamic raw); @protected U8Array32? dco_decode_opt_u_8_array_32(dynamic raw); + @protected + ProductAnalyticsBackend dco_decode_product_analytics_backend(dynamic raw); + + @protected + ProductAnalyticsConfig dco_decode_product_analytics_config(dynamic raw); + + @protected + ProductAnalyticsDeviceClass dco_decode_product_analytics_device_class( + dynamic raw, + ); + + @protected + ProductAnalyticsEvent dco_decode_product_analytics_event(dynamic raw); + + @protected + ProductAnalyticsEventName dco_decode_product_analytics_event_name( + dynamic raw, + ); + + @protected + ProductAnalyticsFlushStatus dco_decode_product_analytics_flush_status( + dynamic raw, + ); + + @protected + ProductAnalyticsNumberProp dco_decode_product_analytics_number_prop( + dynamic raw, + ); + + @protected + ProductAnalyticsSettings dco_decode_product_analytics_settings(dynamic raw); + + @protected + ProductAnalyticsStringProp dco_decode_product_analytics_string_prop( + dynamic raw, + ); + + @protected + ProductAnalyticsTrackStatus dco_decode_product_analytics_track_status( + dynamic raw, + ); + @protected PushPlatform dco_decode_push_platform(dynamic raw); @@ -864,6 +948,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ApiError sse_decode_api_error(SseDeserializer deserializer); + @protected + AptabaseAnalyticsConfig sse_decode_aptabase_analytics_config( + SseDeserializer deserializer, + ); + @protected bool sse_decode_bool(SseDeserializer deserializer); @@ -873,6 +962,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ApiError sse_decode_box_autoadd_api_error(SseDeserializer deserializer); + @protected + AptabaseAnalyticsConfig sse_decode_box_autoadd_aptabase_analytics_config( + SseDeserializer deserializer, + ); + @protected bool sse_decode_box_autoadd_bool(SseDeserializer deserializer); @@ -933,6 +1027,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + ProductAnalyticsConfig sse_decode_box_autoadd_product_analytics_config( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsEvent sse_decode_box_autoadd_product_analytics_event( + SseDeserializer deserializer, + ); + @protected PushRegistration sse_decode_box_autoadd_push_registration( SseDeserializer deserializer, @@ -941,6 +1045,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); + @protected User sse_decode_box_autoadd_user(SseDeserializer deserializer); @@ -988,6 +1095,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected EmojiReaction sse_decode_emoji_reaction(SseDeserializer deserializer); + @protected + double sse_decode_f_64(SseDeserializer deserializer); + @protected FileMetadata sse_decode_file_metadata(SseDeserializer deserializer); @@ -1116,6 +1226,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + List sse_decode_list_product_analytics_number_prop( + SseDeserializer deserializer, + ); + + @protected + List sse_decode_list_product_analytics_string_prop( + SseDeserializer deserializer, + ); + @protected List<(String, String)> sse_decode_list_record_string_string( SseDeserializer deserializer, @@ -1236,6 +1356,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer); + @protected + ProductAnalyticsConfig? sse_decode_opt_box_autoadd_product_analytics_config( + SseDeserializer deserializer, + ); + @protected PushRegistration? sse_decode_opt_box_autoadd_push_registration( SseDeserializer deserializer, @@ -1244,6 +1369,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); + @protected List? sse_decode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( @@ -1253,12 +1381,65 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List? sse_decode_opt_list_String(SseDeserializer deserializer); + @protected + Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); + @protected U8Array12? sse_decode_opt_u_8_array_12(SseDeserializer deserializer); @protected U8Array32? sse_decode_opt_u_8_array_32(SseDeserializer deserializer); + @protected + ProductAnalyticsBackend sse_decode_product_analytics_backend( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsConfig sse_decode_product_analytics_config( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsDeviceClass sse_decode_product_analytics_device_class( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsEvent sse_decode_product_analytics_event( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsEventName sse_decode_product_analytics_event_name( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsFlushStatus sse_decode_product_analytics_flush_status( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsNumberProp sse_decode_product_analytics_number_prop( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsSettings sse_decode_product_analytics_settings( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsStringProp sse_decode_product_analytics_string_prop( + SseDeserializer deserializer, + ); + + @protected + ProductAnalyticsTrackStatus sse_decode_product_analytics_track_status( + SseDeserializer deserializer, + ); + @protected PushPlatform sse_decode_push_platform(SseDeserializer deserializer); @@ -1588,6 +1769,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_api_error(ApiError self, SseSerializer serializer); + @protected + void sse_encode_aptabase_analytics_config( + AptabaseAnalyticsConfig self, + SseSerializer serializer, + ); + @protected void sse_encode_bool(bool self, SseSerializer serializer); @@ -1603,6 +1790,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_aptabase_analytics_config( + AptabaseAnalyticsConfig self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_bool(bool self, SseSerializer serializer); @@ -1678,6 +1871,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_product_analytics_config( + ProductAnalyticsConfig self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_product_analytics_event( + ProductAnalyticsEvent self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_push_registration( PushRegistration self, @@ -1687,6 +1892,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); + @protected void sse_encode_box_autoadd_user(User self, SseSerializer serializer); @@ -1750,6 +1958,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_emoji_reaction(EmojiReaction self, SseSerializer serializer); + @protected + void sse_encode_f_64(double self, SseSerializer serializer); + @protected void sse_encode_file_metadata(FileMetadata self, SseSerializer serializer); @@ -1916,6 +2127,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_list_product_analytics_number_prop( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_product_analytics_string_prop( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_list_record_string_string( List<(String, String)> self, @@ -2060,6 +2283,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_product_analytics_config( + ProductAnalyticsConfig? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_box_autoadd_push_registration( PushRegistration? self, @@ -2069,6 +2298,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); + @protected void sse_encode_opt_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTag( @@ -2079,12 +2311,78 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_opt_list_String(List? self, SseSerializer serializer); + @protected + void sse_encode_opt_list_prim_u_8_strict( + Uint8List? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_u_8_array_12(U8Array12? self, SseSerializer serializer); @protected void sse_encode_opt_u_8_array_32(U8Array32? self, SseSerializer serializer); + @protected + void sse_encode_product_analytics_backend( + ProductAnalyticsBackend self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_config( + ProductAnalyticsConfig self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_device_class( + ProductAnalyticsDeviceClass self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_event( + ProductAnalyticsEvent self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_event_name( + ProductAnalyticsEventName self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_flush_status( + ProductAnalyticsFlushStatus self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_number_prop( + ProductAnalyticsNumberProp self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_settings( + ProductAnalyticsSettings self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_string_prop( + ProductAnalyticsStringProp self, + SseSerializer serializer, + ); + + @protected + void sse_encode_product_analytics_track_status( + ProductAnalyticsTrackStatus self, + SseSerializer serializer, + ); + @protected void sse_encode_push_platform(PushPlatform self, SseSerializer serializer); diff --git a/lib/widgets/wn_slate.dart b/lib/widgets/wn_slate.dart index 322b45738..6bf20963a 100644 --- a/lib/widgets/wn_slate.dart +++ b/lib/widgets/wn_slate.dart @@ -5,6 +5,11 @@ import 'package:whitenoise/theme.dart'; import 'package:whitenoise/widgets/wn_scroll_edge_effect.dart'; import 'package:whitenoise/widgets/wn_slate_content_transition.dart'; +enum WnSlateSystemNoticePosition { + aboveHeader, + belowHeader, +} + class WnSlate extends HookWidget { const WnSlate({ super.key, @@ -14,6 +19,7 @@ class WnSlate extends HookWidget { this.showTopScrollEffect = false, this.showBottomScrollEffect = false, this.systemNotice, + this.systemNoticePosition = WnSlateSystemNoticePosition.aboveHeader, this.child, this.footer, this.animateContent = true, @@ -26,6 +32,7 @@ class WnSlate extends HookWidget { final bool showTopScrollEffect; final bool showBottomScrollEffect; final Widget? systemNotice; + final WnSlateSystemNoticePosition systemNoticePosition; final Widget? child; final Widget? footer; final bool animateContent; @@ -100,8 +107,11 @@ class WnSlate extends HookWidget { final content = Column( mainAxisSize: MainAxisSize.min, children: [ - if (systemNotice != null) systemNotice!, + if (systemNotice != null && systemNoticePosition == WnSlateSystemNoticePosition.aboveHeader) + systemNotice!, if (header != null) header!, + if (systemNotice != null && systemNoticePosition == WnSlateSystemNoticePosition.belowHeader) + systemNotice!, if (childWidget != null) if (shrinkWrapContent) childWidget else Flexible(child: childWidget), if (footer != null) footer!, diff --git a/lib/widgets/wn_system_notice.dart b/lib/widgets/wn_system_notice.dart index a2e2d4745..67174d0b7 100644 --- a/lib/widgets/wn_system_notice.dart +++ b/lib/widgets/wn_system_notice.dart @@ -22,6 +22,11 @@ enum WnSystemNoticeVariant { expanded, } +enum WnSystemNoticeActionLayout { + vertical, + horizontal, +} + const _animationDuration = Duration(milliseconds: 200); const _defaultAutoHideDuration = Duration(seconds: 3); @@ -38,6 +43,9 @@ class WnSystemNotice extends HookWidget { this.onToggle, this.autoHideDuration, this.animateEntrance = true, + this.animateDismiss = true, + this.actionLayout = WnSystemNoticeActionLayout.vertical, + this.backgroundColor, }); final String title; @@ -50,6 +58,9 @@ class WnSystemNotice extends HookWidget { final VoidCallback? onToggle; final Duration? autoHideDuration; final bool animateEntrance; + final bool animateDismiss; + final WnSystemNoticeActionLayout actionLayout; + final Color? backgroundColor; bool get _isCollapsed => variant == WnSystemNoticeVariant.collapsed; bool get _isExpanded => variant == WnSystemNoticeVariant.expanded; @@ -61,6 +72,7 @@ class WnSystemNotice extends HookWidget { final colors = context.colors; final typography = context.typographyScaled; final (bgColor, contentColor, icon) = _getStyle(colors); + final resolvedBgColor = backgroundColor ?? bgColor; final bool shouldShowDetails = !_isCollapsed && !_isTemporary && @@ -109,6 +121,10 @@ class WnSystemNotice extends HookWidget { if (isDismissing.value) return; isDismissing.value = true; autoHideTimer.value?.cancel(); + if (!animateDismiss) { + onDismiss?.call(); + return; + } slideController.reverse().then((_) { if (!context.mounted) return; onDismiss?.call(); @@ -143,7 +159,7 @@ class WnSystemNotice extends HookWidget { child: Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: bgColor, + color: resolvedBgColor, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -199,15 +215,10 @@ class WnSystemNotice extends HookWidget { ], if (primaryAction != null || secondaryAction != null) ...[ Gap(8.h), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (secondaryAction != null) ...[ - secondaryAction!, - Gap(8.h), - ], - if (primaryAction != null) primaryAction!, - ], + _SystemNoticeActions( + layout: actionLayout, + primaryAction: primaryAction, + secondaryAction: secondaryAction, ), ], ], @@ -264,3 +275,46 @@ class WnSystemNotice extends HookWidget { return WnIcons.closeLarge; } } + +class _SystemNoticeActions extends StatelessWidget { + const _SystemNoticeActions({ + required this.layout, + required this.primaryAction, + required this.secondaryAction, + }); + + final WnSystemNoticeActionLayout layout; + final Widget? primaryAction; + final Widget? secondaryAction; + + @override + Widget build(BuildContext context) { + if (layout == WnSystemNoticeActionLayout.horizontal) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (secondaryAction != null) + Expanded( + child: secondaryAction!, + ), + if (secondaryAction != null && primaryAction != null) Gap(8.w), + if (primaryAction != null) + Expanded( + child: primaryAction!, + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (secondaryAction != null) ...[ + secondaryAction!, + Gap(8.h), + ], + if (primaryAction != null) primaryAction!, + ], + ); + } +} diff --git a/lib/widgets/wn_toggle.dart b/lib/widgets/wn_toggle.dart new file mode 100644 index 000000000..a43c37ea6 --- /dev/null +++ b/lib/widgets/wn_toggle.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:whitenoise/theme.dart'; + +class WnToggle extends StatelessWidget { + const WnToggle({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + this.thumbKey, + }); + + final bool value; + final ValueChanged onChanged; + final bool enabled; + final Key? thumbKey; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final trackColor = value ? colors.backgroundContentPrimary : colors.backgroundPrimary; + final thumbColor = value ? colors.backgroundPrimary : colors.backgroundContentTertiary; + + return Opacity( + opacity: enabled ? 1 : 0.5, + child: GestureDetector( + onTap: enabled ? () => onChanged(!value) : null, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + width: 56.w, + height: 28.h, + padding: EdgeInsets.all(4.r), + decoration: BoxDecoration( + color: trackColor, + border: Border.all(color: colors.borderSecondary), + borderRadius: BorderRadius.circular(999.r), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + key: thumbKey, + width: 20.w, + height: 20.w, + decoration: BoxDecoration( + color: thumbColor, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index af242dc91..23845e87c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1698,4 +1698,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.1 <3.16.0" - flutter: ">=3.41.4" + flutter: ">=3.41.4 <4.0.0" diff --git a/reviews/PR-517-start-chat-flow-improvements.md b/reviews/PR-517-start-chat-flow-improvements.md deleted file mode 100644 index d05adc4b3..000000000 --- a/reviews/PR-517-start-chat-flow-improvements.md +++ /dev/null @@ -1,201 +0,0 @@ -# Code Review: PR #517 — Start Chat Flow Improvements - -## Summary - -This PR improves the start-chat UX in several meaningful ways: the loading skeleton for `StartChatScreen` now shows the user avatar immediately while the key-package check runs; metadata is fetched freshly rather than passed through router `extra`; "Chat with support" moves from the search screen to settings; and the app-logs screen gains level-filter toggles. Performance timing is added throughout using a new `logDuration` utility. The changes are well-structured and test coverage is solid. There are a handful of issues worth addressing before merging, mostly around a subtle semantic inconsistency in the filtered-count display and a dead `AnimatedOpacity`. - ---- - -## Issues - -### Logic: `hasFilters` doesn't account for level filtering — filtered count never shows for level-only filters - -**`lib/screens/app_logs_screen.dart:105–108`** - -```dart -final hasFilters = - filter.searchQuery.isNotEmpty || - filter.includePatterns.isNotEmpty || - filter.excludePatterns.isNotEmpty; -``` - -`hasFilters` drives the "showing X of Y" count label. The PR adds level-filter toggles that actively hide entries, but since level selection isn't included in `hasFilters`, when the user filters by level only, the count text never appears — they get no feedback that entries are being hidden. - -The fix is straightforward: - -```dart -final defaultLevels = {Level.WARNING, Level.SEVERE, Level.SHOUT}; -final hasFilters = - filter.searchQuery.isNotEmpty || - filter.includePatterns.isNotEmpty || - filter.excludePatterns.isNotEmpty || - !defaultLevels.containsAll(filter.selectedLevels) || - filter.selectedLevels.length != defaultLevels.length; -``` - -Or simpler: expose an `isDefaultLevels` getter on `AppLogFilterState`. - ---- - -### Bug: `AnimatedOpacity` with hardcoded `opacity: 1` is a no-op - -**`lib/screens/start_chat_screen.dart` (inside `isKeyPackageLoading` branch)** - -```dart -AnimatedOpacity( - opacity: 1, - duration: const Duration(milliseconds: 200), - child: CircularProgressIndicator(...), -), -``` - -The opacity is permanently `1` and never changes — `AnimatedOpacity` has no effect here. The widget goes from not existing (when not loading) to existing at full opacity. Either: -- Remove `AnimatedOpacity` and use a plain widget (current behavior, simpler), or -- Animate it properly by tracking an opacity state variable that starts at 0 and transitions to 1. - -This is pure noise in the widget tree as written. - ---- - -### Semantic inconsistency: `totalEntries` in app logs screen now counts pre-level-filter entries - -**`lib/screens/app_logs_screen.dart:74–76`** - -```dart -final rawEntries = paused.value ? frozenRawEntries.value : liveRawEntries; -final entries = applyFilter(rawEntries); -final totalEntries = rawEntries.length; -``` - -`totalEntries` is used in the "X of Y" filtered count label. Previously it was `liveRawEntries.length` (always live). Now it's `rawEntries.length`, which is correct for the paused case, but the label now reads as "X of Y" where Y is raw-unfiltered-by-level entries, even when INFO is toggled on and increases entry count. The filtered-count label intends to show "how many entries match your text/pattern search out of how many are visible given your log level", but `totalEntries` is still the fully unfiltered count. This is a mild inconsistency — consider whether `totalEntries` should be post-level-filter. - ---- - -### Behavior change: `followState.isLoading` added to the button loading indicator - -**`lib/screens/start_chat_screen.dart` (inside `validActionsColumn`)** - -```dart -loading: showLoadingStates && (followState.isLoading || followState.isActionLoading), -``` - -The old code only showed loading on `followState.isActionLoading`. Adding `followState.isLoading` means the follow button spins during the initial data fetch — which could feel jarring if the fetch is fast (a spinner flash). Confirm this is intentional. If `followState.isLoading` means "initial fetch is in progress", the button probably shouldn't exist at all yet, or should be disabled, rather than showing a spinner. - ---- - -### Missing test: settings screen "Chat with support" doesn't test the `isLoading` guard - -**`test/screens/settings_screen_test.dart`** - -The new `WnMenuItem` in `SettingsScreen` has an early-return guard: - -```dart -onTap: () { - if (helpState.isLoading) return; - ... -} -``` - -There's no test for the loading state scenario — i.e., that tapping while `helpState.isLoading` is true does nothing and doesn't navigate. This is a covered branch in the code but untested. - ---- - -## Suggestions - -### Style: Inconsistent naming — `stopWatch` vs `sw` - -Performance is instrumented in `use_start_dm.dart`, `use_user_has_key_package.dart`, and `start_chat_screen.dart` using `stopWatch`, while `user_service.dart` uses `sw`. Pick one convention and apply it consistently. Given the project emphasises self-documenting code, `stopwatch` (or `sw` uniformly) is fine — just keep it consistent. - ---- - -### Style: Inner function `validActionsColumn` defined in `build` - -**`lib/screens/start_chat_screen.dart:101`** - -```dart -Widget validActionsColumn({bool showLoadingStates = true}) { ... } -``` - -Defining `Widget`-returning functions inside `build` is a pattern the Flutter team discounts — it bypasses element diffing and rebuilds the entire subtree unconditionally. Prefer extracting as a private `StatelessWidget` (screen-scoped, named `_StartChatActionsColumn` per AGENTS.md convention). The `showLoadingStates` flag and the data it needs can be constructor params. - ---- - -### Style: `calloutTitleAndDescription` is also a build-scoped function - -**`lib/screens/start_chat_screen.dart`** - -Same concern as above — `calloutTitleAndDescription()` is a local function that returns a record. It's only called once. Extract it or inline it; the current indirection adds a layer without clarity. - ---- - -### Suggestion: `logDuration` threshold (50ms) is hardcoded and undocumented - -**`lib/utils/logging.dart:3`** - -```dart -void logDuration(Logger logger, String message, int milliseconds) { - if (milliseconds >= 50) { - logger.warning('$message ${milliseconds}ms'); - } -``` - -The 50ms threshold is arbitrary and has no comment explaining why. Either name it as a constant (`_slowThresholdMs`) or add a brief comment. Also: the function doesn't accept an optional threshold, so callers can't tune it for fast vs slow operations. Not blocking, but the magic number will prompt questions later. - ---- - -### Suggestion: `use_start_dm.dart` — stopwatch isn't reset before `createGroup`, total and create times overlap - -**`lib/hooks/use_start_dm.dart:51`** - -```dart -final createGroupStopWatch = Stopwatch()..start(); -final group = await groups_api.createGroup(...); -logDuration(_logger, 'createGroup took', createGroupStopWatch.elapsedMilliseconds); -logDuration(_logger, 'Total DM creation', totalStopWatch.elapsedMilliseconds); -``` - -This is fine — two stopwatches, one measures createGroup only, one is the end-to-end total. The naming (`totalStopWatch` vs `createGroupStopWatch`) is clear. Minor note: `createGroupStopWatch` is never stopped before reading; Dart stopwatch semantics make this correct, but it's worth a comment to signal intent. - ---- - -### Suggestion: `use_user_search.dart` — `follows` variable can be removed - -**`lib/hooks/use_user_search.dart:87–105`** - -```dart -() async { - final stopWatch = Stopwatch()..start(); - try { - final follows = await accounts_api.accountFollows(pubkey: accountPubkey); - return follows; - } finally { - logDuration(...); - } -}, -``` - -`follows` can be returned directly: `return accounts_api.accountFollows(...)`. The intermediate variable serves no purpose. - ---- - -### Design: Duplicate level-filter logic in screen vs provider - -**`lib/screens/app_logs_screen.dart:48–74`** and **`lib/providers/app_log_filter_provider.dart`** - -`AppLogsScreen` re-implements level filtering in its local `applyFilter` function, which mirrors the logic already in `filteredAppLogProvider`. The screen already watches `filteredAppLogProvider` indirectly through `filter` — but it applies the filter again on the raw entries for the paused state. This duplication means any future change to filter logic needs to be applied in two places. Consider whether `frozenRawEntries` could be filtered through the same provider mechanism, or extract the filter logic into a standalone function both can call. - ---- - -## What's Done Well - -- **The loading-skeleton UX approach is clever.** Using `Visibility(maintainSize: true)` to hold space for buttons while showing a spinner prevents layout jank without the complexity of explicit size constraints. The test that validates this (`keeps button layout stable while key package loads`) directly verifies the intent. - -- **`useSupportChat` null-pubkey guard is clean.** Changing `accountPubkey` to `String?` and short-circuiting with `Future.value()` is idiomatic and eliminates a crash path. The test covers it. - -- **`logDuration` utility is a good abstraction.** A small, single-responsibility utility tested with clear threshold boundaries (`>=50ms` → warning, `<50ms` → info). No over-engineering. - -- **Removing `initialMetadata` from `StartChatScreen`** simplifies the component contract. The old pattern of passing stale router `extra` metadata then overwriting it with fresh data was a source of bugs (noted in the PR description). Removing it and always fetching fresh is the right call. - -- **Test updates are thorough.** Tests for the `use_chat_profile` blocking-metadata fallback, the `use_support_chat` null-pubkey case, and the level-toggle provider behaviour all represent genuine regression coverage, not just padding. - -- **Moving "Chat with support" to Settings** is a good UX decision. The search screen is for finding people; putting a support shortcut there was conceptually out of place. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8f42f144b..6efe197de 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "mdk-core" version = "0.8.0" -source = "git+https://github.com/marmot-protocol/mdk?rev=7f809f8549458a0d7f7d885bcdd694023abf299c#7f809f8549458a0d7f7d885bcdd694023abf299c" +source = "git+https://github.com/marmot-protocol/mdk?rev=e8cd584878be908cb95aad260bb7bca65e017de1#e8cd584878be908cb95aad260bb7bca65e017de1" dependencies = [ "base64", "blurhash", @@ -2679,12 +2679,12 @@ dependencies = [ [[package]] name = "mdk-macros" version = "0.8.0" -source = "git+https://github.com/marmot-protocol/mdk?rev=7f809f8549458a0d7f7d885bcdd694023abf299c#7f809f8549458a0d7f7d885bcdd694023abf299c" +source = "git+https://github.com/marmot-protocol/mdk?rev=e8cd584878be908cb95aad260bb7bca65e017de1#e8cd584878be908cb95aad260bb7bca65e017de1" [[package]] name = "mdk-sqlite-storage" version = "0.8.0" -source = "git+https://github.com/marmot-protocol/mdk?rev=7f809f8549458a0d7f7d885bcdd694023abf299c#7f809f8549458a0d7f7d885bcdd694023abf299c" +source = "git+https://github.com/marmot-protocol/mdk?rev=e8cd584878be908cb95aad260bb7bca65e017de1#e8cd584878be908cb95aad260bb7bca65e017de1" dependencies = [ "base64", "getrandom 0.4.2", @@ -2706,7 +2706,7 @@ dependencies = [ [[package]] name = "mdk-storage-traits" version = "0.8.0" -source = "git+https://github.com/marmot-protocol/mdk?rev=7f809f8549458a0d7f7d885bcdd694023abf299c#7f809f8549458a0d7f7d885bcdd694023abf299c" +source = "git+https://github.com/marmot-protocol/mdk?rev=e8cd584878be908cb95aad260bb7bca65e017de1#e8cd584878be908cb95aad260bb7bca65e017de1" dependencies = [ "nostr", "openmls 0.8.1 (git+https://github.com/openmls/openmls?rev=04c50d7fb12d52f4f9aee26de5f5234f3df29fa8)", @@ -4007,6 +4007,7 @@ dependencies = [ "tracing", "url", "whitenoise", + "whitenoise-markdown", ] [[package]] @@ -5521,7 +5522,7 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whitenoise" version = "0.2.1" -source = "git+https://github.com/marmot-protocol/whitenoise-rs?rev=b66230d65891572d4d70cc90cdab3fe8ce654202#b66230d65891572d4d70cc90cdab3fe8ce654202" +source = "git+https://github.com/marmot-protocol/whitenoise-rs?rev=90c76ee89e437cee5f114ba418fe32245ee97c33#90c76ee89e437cee5f114ba418fe32245ee97c33" dependencies = [ "android-native-keyring-store", "apple-native-keyring-store", @@ -5559,6 +5560,7 @@ dependencies = [ "unicode-normalization", "webpki-roots 1.0.6", "whitenoise-macros", + "whitenoise-markdown", "windows-native-keyring-store", "zbus-secret-service-keyring-store", ] @@ -5566,13 +5568,21 @@ dependencies = [ [[package]] name = "whitenoise-macros" version = "0.1.0" -source = "git+https://github.com/marmot-protocol/whitenoise-rs?rev=b66230d65891572d4d70cc90cdab3fe8ce654202#b66230d65891572d4d70cc90cdab3fe8ce654202" +source = "git+https://github.com/marmot-protocol/whitenoise-rs?rev=90c76ee89e437cee5f114ba418fe32245ee97c33#90c76ee89e437cee5f114ba418fe32245ee97c33" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "whitenoise-markdown" +version = "0.1.0" +source = "git+https://github.com/marmot-protocol/whitenoise-rs?rev=90c76ee89e437cee5f114ba418fe32245ee97c33#90c76ee89e437cee5f114ba418fe32245ee97c33" +dependencies = [ + "serde", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index aaa672726..b231dc440 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,7 +22,8 @@ thiserror = "2.0.12" tokio = { version = "1.44", features = ["rt", "rt-multi-thread"] } tracing = "0.1" url = "2.5.1" -whitenoise = { git = "https://github.com/marmot-protocol/whitenoise-rs", rev = "b66230d65891572d4d70cc90cdab3fe8ce654202" } +whitenoise = { git = "https://github.com/marmot-protocol/whitenoise-rs", rev = "90c76ee89e437cee5f114ba418fe32245ee97c33" } +whitenoise-markdown = { git = "https://github.com/marmot-protocol/whitenoise-rs", rev = "90c76ee89e437cee5f114ba418fe32245ee97c33", package = "whitenoise-markdown" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } diff --git a/rust/src/api/groups.rs b/rust/src/api/groups.rs index 301a87544..28667068c 100644 --- a/rust/src/api/groups.rs +++ b/rust/src/api/groups.rs @@ -60,6 +60,7 @@ pub struct FlutterGroupDataUpdate { pub image_key: Option<[u8; 32]>, pub image_hash: Option<[u8; 32]>, pub image_nonce: Option<[u8; 12]>, + pub disappearing_message_secs: Option, } impl From for NostrGroupDataUpdate { @@ -72,6 +73,7 @@ impl From for NostrGroupDataUpdate { image_nonce: group_data.image_nonce.map(Some), image_upload_key: None, nostr_group_id: None, + disappearing_message_secs: group_data.disappearing_message_secs.map(Some), relays: group_data.relays.map(|relays| { relays .into_iter() @@ -269,6 +271,7 @@ pub async fn create_group( image_key: None, image_hash: None, image_nonce: None, + disappearing_message_secs: None, relays: default_relay_urls_parsed()?, admins: admin_pubkeys, }; diff --git a/rust/src/api/media_files.rs b/rust/src/api/media_files.rs index 27fbde825..19c5a0ed3 100644 --- a/rust/src/api/media_files.rs +++ b/rust/src/api/media_files.rs @@ -13,6 +13,8 @@ pub struct FileMetadata { pub dimensions: Option, pub blurhash: Option, pub thumbhash: Option, + pub duration_ms: Option, + pub waveform: Option>, } impl From for FileMetadata { @@ -22,6 +24,8 @@ impl From for FileMetadata { dimensions: metadata.dimensions, blurhash: metadata.blurhash, thumbhash: metadata.thumbhash, + duration_ms: metadata.duration_ms, + waveform: metadata.waveform, } } } @@ -33,6 +37,8 @@ impl From for WhitenoiseFileMetadata { dimensions: metadata.dimensions, blurhash: metadata.blurhash, thumbhash: metadata.thumbhash, + duration_ms: metadata.duration_ms, + waveform: metadata.waveform, } } } diff --git a/rust/src/api/messages.rs b/rust/src/api/messages.rs index 3a3d6e83b..0efe1f4f0 100644 --- a/rust/src/api/messages.rs +++ b/rust/src/api/messages.rs @@ -16,9 +16,11 @@ pub use whitenoise::{ ChatMessage as WhitenoiseChatMessage, DeliveryStatus as WhitenoiseDeliveryStatus, EmojiReaction as WhitenoiseEmojiReaction, MediaFile as WhitenoiseMediaFile, MessageUpdate as WhitenoiseMessageUpdate, MessageWithTokens as WhitenoiseMessageWithTokens, - ReactionSummary as WhitenoiseReactionSummary, SerializableToken as WhitenoiseSerializableToken, - UpdateTrigger as WhitenoiseUpdateTrigger, UserReaction as WhitenoiseUserReaction, Whitenoise, + ReactionSummary as WhitenoiseReactionSummary, UpdateTrigger as WhitenoiseUpdateTrigger, + UserReaction as WhitenoiseUserReaction, Whitenoise, }; +use whitenoise_markdown::{Block as MarkdownBlock, Document as MarkdownDocument}; +use whitenoise_markdown::{Inline as MarkdownInline, NostrEntity as MarkdownNostrEntity}; /// Flutter-compatible message with tokens #[frb(non_opaque)] @@ -87,6 +89,127 @@ pub struct SerializableToken { pub content: Option, // None for LineBreak and Whitespace } +fn tokens_from_document(document: &MarkdownDocument) -> Vec { + let mut tokens = Vec::new(); + for (index, block) in document.blocks.iter().enumerate() { + if index > 0 { + tokens.push(line_break_token()); + } + push_block_tokens(block, &mut tokens); + } + tokens +} + +fn push_block_tokens(block: &MarkdownBlock, tokens: &mut Vec) { + match block { + MarkdownBlock::Paragraph { inlines } | MarkdownBlock::Heading { inlines, .. } => { + push_inline_tokens(inlines, tokens); + } + MarkdownBlock::ThematicBreak => tokens.push(line_break_token()), + MarkdownBlock::CodeBlock { content, .. } | MarkdownBlock::MathBlock { content } => { + tokens.push(text_token(content)); + } + MarkdownBlock::BlockQuote { blocks } => { + for (index, block) in blocks.iter().enumerate() { + if index > 0 { + tokens.push(line_break_token()); + } + push_block_tokens(block, tokens); + } + } + MarkdownBlock::List { items, .. } => { + for (index, item) in items.iter().enumerate() { + if index > 0 { + tokens.push(line_break_token()); + } + for block in &item.blocks { + push_block_tokens(block, tokens); + } + } + } + MarkdownBlock::Table { header, rows, .. } => { + for (index, cell) in header.iter().enumerate() { + if index > 0 { + tokens.push(whitespace_token()); + } + push_inline_tokens(&cell.inlines, tokens); + } + for row in rows { + tokens.push(line_break_token()); + for (index, cell) in row.iter().enumerate() { + if index > 0 { + tokens.push(whitespace_token()); + } + push_inline_tokens(&cell.inlines, tokens); + } + } + } + } +} + +fn push_inline_tokens(inlines: &[MarkdownInline], tokens: &mut Vec) { + for inline in inlines { + match inline { + MarkdownInline::Text(text) + | MarkdownInline::Code(text) + | MarkdownInline::Math(text) => tokens.push(text_token(text)), + MarkdownInline::SoftBreak | MarkdownInline::HardBreak => { + tokens.push(line_break_token()) + } + MarkdownInline::Emph(children) + | MarkdownInline::Strong(children) + | MarkdownInline::Strikethrough(children) => push_inline_tokens(children, tokens), + MarkdownInline::Link { dest, children, .. } => { + if children.is_empty() { + tokens.push(url_token(dest)); + } else { + push_inline_tokens(children, tokens); + } + } + MarkdownInline::Image { alt, .. } => push_inline_tokens(alt, tokens), + MarkdownInline::Autolink { url, .. } => tokens.push(url_token(url)), + MarkdownInline::NostrMention(entity) | MarkdownInline::NostrUri(entity) => { + tokens.push(nostr_token(entity)); + } + } + } +} + +fn text_token(content: &str) -> SerializableToken { + SerializableToken { + token_type: "Text".to_string(), + content: Some(content.to_string()), + } +} + +fn url_token(content: &str) -> SerializableToken { + SerializableToken { + token_type: "Url".to_string(), + content: Some(content.to_string()), + } +} + +fn nostr_token(entity: &MarkdownNostrEntity) -> SerializableToken { + SerializableToken { + token_type: "Nostr".to_string(), + content: Some(format!("nostr:{}", entity.bech32)), + } +} + +fn line_break_token() -> SerializableToken { + SerializableToken { + token_type: "LineBreak".to_string(), + content: None, + } +} + +fn whitespace_token() -> SerializableToken { + SerializableToken { + token_type: "Whitespace".to_string(), + content: None, + } +} + /// Tracks the delivery state of an outgoing message. /// /// Follows an optimistic UI pattern: the message appears instantly with `Sending`, @@ -202,12 +325,7 @@ impl From for SearchResult { impl From<&WhitenoiseMessageWithTokens> for MessageWithTokens { fn from(message_with_tokens: &WhitenoiseMessageWithTokens) -> Self { - // Convert tokens to Flutter-compatible representation - let tokens = message_with_tokens - .tokens - .iter() - .map(|token| token.into()) - .collect(); + let tokens = tokens_from_document(&message_with_tokens.tokens); Self { id: message_with_tokens.message.id.to_hex(), @@ -232,43 +350,6 @@ impl From for MessageWithTokens { } } -impl From<&WhitenoiseSerializableToken> for SerializableToken { - fn from(token: &WhitenoiseSerializableToken) -> Self { - match token { - WhitenoiseSerializableToken::Nostr(s) => Self { - token_type: "Nostr".to_string(), - content: Some(s.clone()), - }, - WhitenoiseSerializableToken::Url(s) => Self { - token_type: "Url".to_string(), - content: Some(s.clone()), - }, - WhitenoiseSerializableToken::Hashtag(s) => Self { - token_type: "Hashtag".to_string(), - content: Some(s.clone()), - }, - WhitenoiseSerializableToken::Text(s) => Self { - token_type: "Text".to_string(), - content: Some(s.clone()), - }, - WhitenoiseSerializableToken::LineBreak => Self { - token_type: "LineBreak".to_string(), - content: None, - }, - WhitenoiseSerializableToken::Whitespace => Self { - token_type: "Whitespace".to_string(), - content: None, - }, - } - } -} - -impl From for SerializableToken { - fn from(token: WhitenoiseSerializableToken) -> Self { - (&token).into() - } -} - impl From for ChatMessageSummary { fn from(summary: WhitenoiseChatMessageSummary) -> Self { Self { @@ -352,12 +433,7 @@ impl From<&WhitenoiseChatMessage> for ChatMessage { .map(|tag| tag.as_slice().to_vec()) .collect(); - // Convert content tokens to proper Flutter-compatible structs - let content_tokens = chat_message - .content_tokens - .iter() - .map(|token| token.into()) - .collect(); + let content_tokens = tokens_from_document(&chat_message.content_tokens); // Convert reactions to proper Flutter-compatible struct let reactions = (&chat_message.reactions).into(); diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index 780c09c18..db4a28283 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -44,6 +44,9 @@ pub struct WhitenoiseConfig { pub logs_dir: String, /// Relay URLs used when the app needs to create default relay metadata. pub default_relay_urls: Option>, + /// Optional product analytics configuration. Consent is still device-local + /// and disabled by default inside whitenoise-rs. + pub product_analytics_config: Option, } impl From for WhitenoiseConfig { @@ -52,6 +55,10 @@ impl From for WhitenoiseConfig { data_dir: config.data_dir.to_string_lossy().to_string(), logs_dir: config.logs_dir.to_string_lossy().to_string(), default_relay_urls: None, + product_analytics_config: config + .product_analytics_config + .clone() + .map(product_analytics::ProductAnalyticsConfig::from), } } } @@ -81,11 +88,13 @@ pub fn create_whitenoise_config( data_dir: String, logs_dir: String, default_relay_urls: Option>, + product_analytics_config: Option, ) -> WhitenoiseConfig { WhitenoiseConfig { data_dir, logs_dir, default_relay_urls, + product_analytics_config, } } @@ -105,6 +114,7 @@ pub mod messages; pub mod metadata; pub mod mute_list; pub mod notifications; +pub mod product_analytics; pub mod relay_defaults; pub mod relays; pub mod signer; @@ -129,6 +139,7 @@ pub use messages::*; pub use metadata::*; pub use mute_list::*; pub use notifications::*; +pub use product_analytics::*; pub use relay_defaults::*; pub use relays::*; pub use signer::*; @@ -140,11 +151,14 @@ pub use zapstore::*; #[frb] pub async fn initialize_whitenoise(config: WhitenoiseConfig) -> Result<(), ApiError> { let default_relay_urls = config.default_relay_urls.clone(); - let core_config = whitenoise::WhitenoiseConfig::new( + let mut core_config = whitenoise::WhitenoiseConfig::new( Path::new(&config.data_dir), Path::new(&config.logs_dir), "com.whitenoise.app", ); + if let Some(product_analytics_config) = config.product_analytics_config { + core_config = core_config.with_product_analytics_config(product_analytics_config.into()); + } GLOBAL_WN .get_or_try_init(|| async move { configure_default_relay_urls(default_relay_urls)?; diff --git a/rust/src/api/product_analytics.rs b/rust/src/api/product_analytics.rs new file mode 100644 index 000000000..ddfb23995 --- /dev/null +++ b/rust/src/api/product_analytics.rs @@ -0,0 +1,357 @@ +use crate::api::{error::ApiError, wn}; +use chrono::{DateTime, Utc}; +use flutter_rust_bridge::frb; +use whitenoise::{ + AptabaseAnalyticsConfig as WhitenoiseAptabaseAnalyticsConfig, + PRODUCT_ANALYTICS_CONSENT_VERSION, + ProductAnalyticsBackend as WhitenoiseProductAnalyticsBackend, + ProductAnalyticsConfig as WhitenoiseProductAnalyticsConfig, + ProductAnalyticsDeviceClass as WhitenoiseProductAnalyticsDeviceClass, + ProductAnalyticsEvent as WhitenoiseProductAnalyticsEvent, + ProductAnalyticsEventName as WhitenoiseProductAnalyticsEventName, + ProductAnalyticsFlushStatus as WhitenoiseProductAnalyticsFlushStatus, + ProductAnalyticsNumberProp as WhitenoiseProductAnalyticsNumberProp, + ProductAnalyticsSettings as WhitenoiseProductAnalyticsSettings, + ProductAnalyticsStringProp as WhitenoiseProductAnalyticsStringProp, + ProductAnalyticsTrackStatus as WhitenoiseProductAnalyticsTrackStatus, +}; + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductAnalyticsSettings { + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub consent_version: String, +} + +impl From for ProductAnalyticsSettings { + fn from(settings: WhitenoiseProductAnalyticsSettings) -> Self { + Self { + enabled: settings.enabled, + created_at: settings.created_at, + updated_at: settings.updated_at, + consent_version: settings.consent_version, + } + } +} + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductAnalyticsConfig { + pub backend: ProductAnalyticsBackend, + pub app_version: String, + pub bundle_identifier: String, + pub device_class: ProductAnalyticsDeviceClass, + pub os_name: String, + pub locale: String, + pub is_debug: bool, +} + +impl From for WhitenoiseProductAnalyticsConfig { + fn from(config: ProductAnalyticsConfig) -> Self { + Self { + backend: config.backend.into(), + app_version: config.app_version, + bundle_identifier: config.bundle_identifier, + device_class: config.device_class.into(), + os_name: config.os_name, + locale: config.locale, + is_debug: config.is_debug, + } + } +} + +impl From for ProductAnalyticsConfig { + fn from(config: WhitenoiseProductAnalyticsConfig) -> Self { + Self { + backend: config.backend.into(), + app_version: config.app_version, + bundle_identifier: config.bundle_identifier, + device_class: config.device_class.into(), + os_name: config.os_name, + locale: config.locale, + is_debug: config.is_debug, + } + } +} + +#[frb] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProductAnalyticsBackend { + Disabled, + Aptabase { config: AptabaseAnalyticsConfig }, +} + +impl From for WhitenoiseProductAnalyticsBackend { + fn from(backend: ProductAnalyticsBackend) -> Self { + match backend { + ProductAnalyticsBackend::Disabled => Self::Disabled, + ProductAnalyticsBackend::Aptabase { config } => Self::Aptabase(config.into()), + } + } +} + +impl From for ProductAnalyticsBackend { + fn from(backend: WhitenoiseProductAnalyticsBackend) -> Self { + match backend { + WhitenoiseProductAnalyticsBackend::Disabled => Self::Disabled, + WhitenoiseProductAnalyticsBackend::Aptabase(config) => Self::Aptabase { + config: config.into(), + }, + } + } +} + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AptabaseAnalyticsConfig { + pub app_key: String, + pub host: String, +} + +impl From for WhitenoiseAptabaseAnalyticsConfig { + fn from(config: AptabaseAnalyticsConfig) -> Self { + Self { + app_key: config.app_key, + host: config.host, + } + } +} + +impl From for AptabaseAnalyticsConfig { + fn from(config: WhitenoiseAptabaseAnalyticsConfig) -> Self { + Self { + app_key: config.app_key, + host: config.host, + } + } +} + +#[frb] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductAnalyticsDeviceClass { + Phone, + Tablet, + Desktop, + Unknown, +} + +impl From for WhitenoiseProductAnalyticsDeviceClass { + fn from(device_class: ProductAnalyticsDeviceClass) -> Self { + match device_class { + ProductAnalyticsDeviceClass::Phone => Self::Phone, + ProductAnalyticsDeviceClass::Tablet => Self::Tablet, + ProductAnalyticsDeviceClass::Desktop => Self::Desktop, + ProductAnalyticsDeviceClass::Unknown => Self::Unknown, + } + } +} + +impl From for ProductAnalyticsDeviceClass { + fn from(device_class: WhitenoiseProductAnalyticsDeviceClass) -> Self { + match device_class { + WhitenoiseProductAnalyticsDeviceClass::Phone => Self::Phone, + WhitenoiseProductAnalyticsDeviceClass::Tablet => Self::Tablet, + WhitenoiseProductAnalyticsDeviceClass::Desktop => Self::Desktop, + WhitenoiseProductAnalyticsDeviceClass::Unknown => Self::Unknown, + } + } +} + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq)] +pub struct ProductAnalyticsEvent { + pub name: ProductAnalyticsEventName, + pub string_props: Vec, + pub number_props: Vec, +} + +impl From for WhitenoiseProductAnalyticsEvent { + fn from(event: ProductAnalyticsEvent) -> Self { + let mut converted = WhitenoiseProductAnalyticsEvent::new(event.name.into()); + for prop in event.string_props { + converted.string_props.push(prop.into()); + } + for prop in event.number_props { + converted.number_props.push(prop.into()); + } + converted + } +} + +#[frb] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductAnalyticsEventName { + AnalyticsEnabled, + AppStarted, + AppForegrounded, + AppBackgrounded, + OnboardingStarted, + OnboardingCompleted, + IdentityCreated, + LoginStarted, + LoginCompleted, + LoginFailed, + MessageSendStarted, + MessageSendCompleted, + MessageSendFailed, + GroupCreateStarted, + GroupCreateCompleted, + GroupCreateFailed, + MembersAdded, + MembersRemoved, + GroupDataUpdated, + MediaUploadStarted, + MediaUploadCompleted, + MediaUploadFailed, + PushRegistrationCompleted, + PushRegistrationFailed, + SettingChanged, +} + +impl From for WhitenoiseProductAnalyticsEventName { + fn from(name: ProductAnalyticsEventName) -> Self { + match name { + ProductAnalyticsEventName::AnalyticsEnabled => Self::AnalyticsEnabled, + ProductAnalyticsEventName::AppStarted => Self::AppStarted, + ProductAnalyticsEventName::AppForegrounded => Self::AppForegrounded, + ProductAnalyticsEventName::AppBackgrounded => Self::AppBackgrounded, + ProductAnalyticsEventName::OnboardingStarted => Self::OnboardingStarted, + ProductAnalyticsEventName::OnboardingCompleted => Self::OnboardingCompleted, + ProductAnalyticsEventName::IdentityCreated => Self::IdentityCreated, + ProductAnalyticsEventName::LoginStarted => Self::LoginStarted, + ProductAnalyticsEventName::LoginCompleted => Self::LoginCompleted, + ProductAnalyticsEventName::LoginFailed => Self::LoginFailed, + ProductAnalyticsEventName::MessageSendStarted => Self::MessageSendStarted, + ProductAnalyticsEventName::MessageSendCompleted => Self::MessageSendCompleted, + ProductAnalyticsEventName::MessageSendFailed => Self::MessageSendFailed, + ProductAnalyticsEventName::GroupCreateStarted => Self::GroupCreateStarted, + ProductAnalyticsEventName::GroupCreateCompleted => Self::GroupCreateCompleted, + ProductAnalyticsEventName::GroupCreateFailed => Self::GroupCreateFailed, + ProductAnalyticsEventName::MembersAdded => Self::MembersAdded, + ProductAnalyticsEventName::MembersRemoved => Self::MembersRemoved, + ProductAnalyticsEventName::GroupDataUpdated => Self::GroupDataUpdated, + ProductAnalyticsEventName::MediaUploadStarted => Self::MediaUploadStarted, + ProductAnalyticsEventName::MediaUploadCompleted => Self::MediaUploadCompleted, + ProductAnalyticsEventName::MediaUploadFailed => Self::MediaUploadFailed, + ProductAnalyticsEventName::PushRegistrationCompleted => Self::PushRegistrationCompleted, + ProductAnalyticsEventName::PushRegistrationFailed => Self::PushRegistrationFailed, + ProductAnalyticsEventName::SettingChanged => Self::SettingChanged, + } + } +} + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductAnalyticsStringProp { + pub key: String, + pub value: String, +} + +impl From for WhitenoiseProductAnalyticsStringProp { + fn from(prop: ProductAnalyticsStringProp) -> Self { + Self { + key: prop.key, + value: prop.value, + } + } +} + +#[frb(non_opaque)] +#[derive(Debug, Clone, PartialEq)] +pub struct ProductAnalyticsNumberProp { + pub key: String, + pub value: f64, +} + +impl From for WhitenoiseProductAnalyticsNumberProp { + fn from(prop: ProductAnalyticsNumberProp) -> Self { + Self { + key: prop.key, + value: prop.value, + } + } +} + +#[frb] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductAnalyticsTrackStatus { + Queued, + IgnoredDisabled, + IgnoredUnconfigured, +} + +impl From for ProductAnalyticsTrackStatus { + fn from(status: WhitenoiseProductAnalyticsTrackStatus) -> Self { + match status { + WhitenoiseProductAnalyticsTrackStatus::Queued => Self::Queued, + WhitenoiseProductAnalyticsTrackStatus::IgnoredDisabled => Self::IgnoredDisabled, + WhitenoiseProductAnalyticsTrackStatus::IgnoredUnconfigured => Self::IgnoredUnconfigured, + } + } +} + +#[frb] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductAnalyticsFlushStatus { + Flushed, + NothingToFlush, + Disabled, + Unconfigured, + TimedOut, +} + +impl From for ProductAnalyticsFlushStatus { + fn from(status: WhitenoiseProductAnalyticsFlushStatus) -> Self { + match status { + WhitenoiseProductAnalyticsFlushStatus::Flushed => Self::Flushed, + WhitenoiseProductAnalyticsFlushStatus::NothingToFlush => Self::NothingToFlush, + WhitenoiseProductAnalyticsFlushStatus::Disabled => Self::Disabled, + WhitenoiseProductAnalyticsFlushStatus::Unconfigured => Self::Unconfigured, + WhitenoiseProductAnalyticsFlushStatus::TimedOut => Self::TimedOut, + } + } +} + +#[frb] +pub fn product_analytics_consent_version() -> String { + PRODUCT_ANALYTICS_CONSENT_VERSION.to_string() +} + +#[frb] +pub async fn product_analytics_settings() -> Result { + let whitenoise = wn()?; + let settings = whitenoise.product_analytics_settings().await?; + Ok(settings.into()) +} + +#[frb] +pub async fn set_product_analytics_enabled( + enabled: bool, + consent_version: String, +) -> Result { + let whitenoise = wn()?; + let settings = whitenoise + .set_product_analytics_enabled(enabled, consent_version) + .await?; + Ok(settings.into()) +} + +#[frb] +pub async fn track_product_analytics_event( + event: ProductAnalyticsEvent, +) -> Result { + let whitenoise = wn()?; + let status = whitenoise + .track_product_analytics_event(event.into()) + .await?; + Ok(status.into()) +} + +#[frb] +pub async fn flush_product_analytics() -> Result { + let whitenoise = wn()?; + let status = whitenoise.flush_product_analytics().await?; + Ok(status.into()) +} diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 70939a0b6..c3f00001c 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -45,7 +45,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1064693455; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2006763366; // Section: executor @@ -907,6 +907,9 @@ fn wire__crate__api__create_whitenoise_config_impl( let api_data_dir = ::sse_decode(&mut deserializer); let api_logs_dir = ::sse_decode(&mut deserializer); let api_default_relay_urls = >>::sse_decode(&mut deserializer); + let api_product_analytics_config = >::sse_decode(&mut deserializer); deserializer.end(); move |context| { transform_result_sse::<_, ()>((move || { @@ -914,6 +917,7 @@ fn wire__crate__api__create_whitenoise_config_impl( api_data_dir, api_logs_dir, api_default_relay_urls, + api_product_analytics_config, ))?; Ok(output_ok) })()) @@ -1566,6 +1570,42 @@ fn wire__crate__api__messages__fetch_messages_unread_with_minimum_impl( }, ) } +fn wire__crate__api__product_analytics__flush_product_analytics_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "flush_product_analytics", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::error::ApiError>( + (move || async move { + let output_ok = + crate::api::product_analytics::flush_product_analytics().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__accounts__follow_user_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3669,6 +3709,76 @@ fn wire__crate__api__utils__npub_from_hex_pubkey_impl( }, ) } +fn wire__crate__api__product_analytics__product_analytics_consent_version_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "product_analytics_consent_version", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok( + crate::api::product_analytics::product_analytics_consent_version(), + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__product_analytics__product_analytics_settings_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "product_analytics_settings", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::error::ApiError>( + (move || async move { + let output_ok = + crate::api::product_analytics::product_analytics_settings().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__accounts__publish_account_key_package_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4432,6 +4542,48 @@ fn wire__crate__api__chat_list__set_chat_pin_order_impl( }, ) } +fn wire__crate__api__product_analytics__set_product_analytics_enabled_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_product_analytics_enabled", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_enabled = ::sse_decode(&mut deserializer); + let api_consent_version = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::error::ApiError>( + (move || async move { + let output_ok = + crate::api::product_analytics::set_product_analytics_enabled( + api_enabled, + api_consent_version, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__utils__string_from_relay_url_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4960,6 +5112,46 @@ fn wire__crate__api__utils__theme_mode_to_string_impl( }, ) } +fn wire__crate__api__product_analytics__track_product_analytics_event_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "track_product_analytics_event", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_event = ::sse_decode( + &mut deserializer, + ); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::error::ApiError>( + (move || async move { + let output_ok = + crate::api::product_analytics::track_product_analytics_event(api_event) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__account_groups__unarchive_chat_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -6187,6 +6379,18 @@ impl SseDecode for crate::api::error::ApiError { } } +impl SseDecode for crate::api::product_analytics::AptabaseAnalyticsConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_appKey = ::sse_decode(deserializer); + let mut var_host = ::sse_decode(deserializer); + return crate::api::product_analytics::AptabaseAnalyticsConfig { + app_key: var_appKey, + host: var_host, + }; + } +} + impl SseDecode for bool { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6445,6 +6649,13 @@ impl SseDecode for crate::api::messages::EmojiReaction { } } +impl SseDecode for f64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_f64::().unwrap() + } +} + impl SseDecode for crate::api::media_files::FileMetadata { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6452,11 +6663,15 @@ impl SseDecode for crate::api::media_files::FileMetadata { let mut var_dimensions = >::sse_decode(deserializer); let mut var_blurhash = >::sse_decode(deserializer); let mut var_thumbhash = >::sse_decode(deserializer); + let mut var_durationMs = >::sse_decode(deserializer); + let mut var_waveform = >>::sse_decode(deserializer); return crate::api::media_files::FileMetadata { original_filename: var_originalFilename, dimensions: var_dimensions, blurhash: var_blurhash, thumbhash: var_thumbhash, + duration_ms: var_durationMs, + waveform: var_waveform, }; } } @@ -6491,6 +6706,7 @@ impl SseDecode for crate::api::groups::FlutterGroupDataUpdate { let mut var_imageKey = >::sse_decode(deserializer); let mut var_imageHash = >::sse_decode(deserializer); let mut var_imageNonce = >::sse_decode(deserializer); + let mut var_disappearingMessageSecs = >::sse_decode(deserializer); return crate::api::groups::FlutterGroupDataUpdate { name: var_name, description: var_description, @@ -6499,6 +6715,7 @@ impl SseDecode for crate::api::groups::FlutterGroupDataUpdate { image_key: var_imageKey, image_hash: var_imageHash, image_nonce: var_imageNonce, + disappearing_message_secs: var_disappearingMessageSecs, }; } } @@ -6926,6 +7143,38 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push( + ::sse_decode( + deserializer, + ), + ); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push( + ::sse_decode( + deserializer, + ), + ); + } + return ans_; + } +} + impl SseDecode for Vec<(String, String)> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7366,6 +7615,19 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some( + ::sse_decode(deserializer), + ); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7390,6 +7652,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7412,6 +7685,17 @@ impl SseDecode for Option> { } } +impl SseDecode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(>::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option<[u8; 12]> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7434,22 +7718,210 @@ impl SseDecode for Option<[u8; 32]> { } } -impl SseDecode for crate::api::notifications::PushPlatform { +impl SseDecode for crate::api::product_analytics::ProductAnalyticsBackend { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::product_analytics::ProductAnalyticsBackend::Disabled; + } + 1 => { + let mut var_config = + ::sse_decode( + deserializer, + ); + return crate::api::product_analytics::ProductAnalyticsBackend::Aptabase { + config: var_config, + }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_backend = + ::sse_decode(deserializer); + let mut var_appVersion = ::sse_decode(deserializer); + let mut var_bundleIdentifier = ::sse_decode(deserializer); + let mut var_deviceClass = + ::sse_decode(deserializer); + let mut var_osName = ::sse_decode(deserializer); + let mut var_locale = ::sse_decode(deserializer); + let mut var_isDebug = ::sse_decode(deserializer); + return crate::api::product_analytics::ProductAnalyticsConfig { + backend: var_backend, + app_version: var_appVersion, + bundle_identifier: var_bundleIdentifier, + device_class: var_deviceClass, + os_name: var_osName, + locale: var_locale, + is_debug: var_isDebug, + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsDeviceClass { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut inner = ::sse_decode(deserializer); return match inner { - 0 => crate::api::notifications::PushPlatform::Apns, - 1 => crate::api::notifications::PushPlatform::Fcm, - _ => unreachable!("Invalid variant for PushPlatform: {}", inner), + 0 => crate::api::product_analytics::ProductAnalyticsDeviceClass::Phone, + 1 => crate::api::product_analytics::ProductAnalyticsDeviceClass::Tablet, + 2 => crate::api::product_analytics::ProductAnalyticsDeviceClass::Desktop, + 3 => crate::api::product_analytics::ProductAnalyticsDeviceClass::Unknown, + _ => unreachable!("Invalid variant for ProductAnalyticsDeviceClass: {}", inner), }; } } -impl SseDecode for crate::api::notifications::PushRegistration { +impl SseDecode for crate::api::product_analytics::ProductAnalyticsEvent { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut var_accountPubkey = ::sse_decode(deserializer); + let mut var_name = + ::sse_decode(deserializer); + let mut var_stringProps = + >::sse_decode( + deserializer, + ); + let mut var_numberProps = + >::sse_decode( + deserializer, + ); + return crate::api::product_analytics::ProductAnalyticsEvent { + name: var_name, + string_props: var_stringProps, + number_props: var_numberProps, + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsEventName { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::product_analytics::ProductAnalyticsEventName::AnalyticsEnabled, + 1 => crate::api::product_analytics::ProductAnalyticsEventName::AppStarted, + 2 => crate::api::product_analytics::ProductAnalyticsEventName::AppForegrounded, + 3 => crate::api::product_analytics::ProductAnalyticsEventName::AppBackgrounded, + 4 => crate::api::product_analytics::ProductAnalyticsEventName::OnboardingStarted, + 5 => crate::api::product_analytics::ProductAnalyticsEventName::OnboardingCompleted, + 6 => crate::api::product_analytics::ProductAnalyticsEventName::IdentityCreated, + 7 => crate::api::product_analytics::ProductAnalyticsEventName::LoginStarted, + 8 => crate::api::product_analytics::ProductAnalyticsEventName::LoginCompleted, + 9 => crate::api::product_analytics::ProductAnalyticsEventName::LoginFailed, + 10 => crate::api::product_analytics::ProductAnalyticsEventName::MessageSendStarted, + 11 => crate::api::product_analytics::ProductAnalyticsEventName::MessageSendCompleted, + 12 => crate::api::product_analytics::ProductAnalyticsEventName::MessageSendFailed, + 13 => crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateStarted, + 14 => crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateCompleted, + 15 => crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateFailed, + 16 => crate::api::product_analytics::ProductAnalyticsEventName::MembersAdded, + 17 => crate::api::product_analytics::ProductAnalyticsEventName::MembersRemoved, + 18 => crate::api::product_analytics::ProductAnalyticsEventName::GroupDataUpdated, + 19 => crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadStarted, + 20 => crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadCompleted, + 21 => crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadFailed, + 22 => { + crate::api::product_analytics::ProductAnalyticsEventName::PushRegistrationCompleted + } + 23 => crate::api::product_analytics::ProductAnalyticsEventName::PushRegistrationFailed, + 24 => crate::api::product_analytics::ProductAnalyticsEventName::SettingChanged, + _ => unreachable!("Invalid variant for ProductAnalyticsEventName: {}", inner), + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsFlushStatus { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::product_analytics::ProductAnalyticsFlushStatus::Flushed, + 1 => crate::api::product_analytics::ProductAnalyticsFlushStatus::NothingToFlush, + 2 => crate::api::product_analytics::ProductAnalyticsFlushStatus::Disabled, + 3 => crate::api::product_analytics::ProductAnalyticsFlushStatus::Unconfigured, + 4 => crate::api::product_analytics::ProductAnalyticsFlushStatus::TimedOut, + _ => unreachable!("Invalid variant for ProductAnalyticsFlushStatus: {}", inner), + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsNumberProp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_key = ::sse_decode(deserializer); + let mut var_value = ::sse_decode(deserializer); + return crate::api::product_analytics::ProductAnalyticsNumberProp { + key: var_key, + value: var_value, + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsSettings { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_enabled = ::sse_decode(deserializer); + let mut var_createdAt = >::sse_decode(deserializer); + let mut var_updatedAt = >::sse_decode(deserializer); + let mut var_consentVersion = ::sse_decode(deserializer); + return crate::api::product_analytics::ProductAnalyticsSettings { + enabled: var_enabled, + created_at: var_createdAt, + updated_at: var_updatedAt, + consent_version: var_consentVersion, + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsStringProp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_key = ::sse_decode(deserializer); + let mut var_value = ::sse_decode(deserializer); + return crate::api::product_analytics::ProductAnalyticsStringProp { + key: var_key, + value: var_value, + }; + } +} + +impl SseDecode for crate::api::product_analytics::ProductAnalyticsTrackStatus { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::product_analytics::ProductAnalyticsTrackStatus::Queued, + 1 => crate::api::product_analytics::ProductAnalyticsTrackStatus::IgnoredDisabled, + 2 => crate::api::product_analytics::ProductAnalyticsTrackStatus::IgnoredUnconfigured, + _ => unreachable!("Invalid variant for ProductAnalyticsTrackStatus: {}", inner), + }; + } +} + +impl SseDecode for crate::api::notifications::PushPlatform { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::notifications::PushPlatform::Apns, + 1 => crate::api::notifications::PushPlatform::Fcm, + _ => unreachable!("Invalid variant for PushPlatform: {}", inner), + }; + } +} + +impl SseDecode for crate::api::notifications::PushRegistration { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_accountPubkey = ::sse_decode(deserializer); let mut var_platform = ::sse_decode(deserializer); let mut var_rawToken = ::sse_decode(deserializer); let mut var_serverPubkey = ::sse_decode(deserializer); @@ -7817,10 +8289,14 @@ impl SseDecode for crate::api::WhitenoiseConfig { let mut var_dataDir = ::sse_decode(deserializer); let mut var_logsDir = ::sse_decode(deserializer); let mut var_defaultRelayUrls = >>::sse_decode(deserializer); + let mut var_productAnalyticsConfig = >::sse_decode(deserializer); return crate::api::WhitenoiseConfig { data_dir: var_dataDir, logs_dir: var_logsDir, default_relay_urls: var_defaultRelayUrls, + product_analytics_config: var_productAnalyticsConfig, }; } } @@ -7944,273 +8420,303 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 39 => wire__crate__api__accounts__follow_user_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__accounts__get_account_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__account_groups__get_account_group_impl( + 39 => wire__crate__api__product_analytics__flush_product_analytics_impl( port, ptr, rust_vec_len, data_len, ), - 42 => wire__crate__api__accounts__get_accounts_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__get_app_settings_impl(port, ptr, rust_vec_len, data_len), - 44 => { + 40 => wire__crate__api__accounts__follow_user_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__accounts__get_account_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__account_groups__get_account_group_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 43 => wire__crate__api__accounts__get_accounts_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__get_app_settings_impl(port, ptr, rust_vec_len, data_len), + 45 => { wire__crate__api__mute_list__get_blocked_users_impl(port, ptr, rust_vec_len, data_len) } - 45 => wire__crate__api__chat_list__get_chat_list_impl(port, ptr, rust_vec_len, data_len), - 46 => { + 46 => wire__crate__api__chat_list__get_chat_list_impl(port, ptr, rust_vec_len, data_len), + 47 => { wire__crate__api__chat_summary__get_chat_summary_impl(port, ptr, rust_vec_len, data_len) } - 47 => wire__crate__api__utils__get_default_blossom_server_url_impl( + 48 => wire__crate__api__utils__get_default_blossom_server_url_impl( port, ptr, rust_vec_len, data_len, ), - 48 => wire__crate__api__account_groups__get_dm_group_with_peer_impl( + 49 => wire__crate__api__account_groups__get_dm_group_with_peer_impl( port, ptr, rust_vec_len, data_len, ), - 49 => wire__crate__api__groups__get_group_impl(port, ptr, rust_vec_len, data_len), - 50 => { + 50 => wire__crate__api__groups__get_group_impl(port, ptr, rust_vec_len, data_len), + 51 => { wire__crate__api__groups__get_group_image_path_impl(port, ptr, rust_vec_len, data_len) } - 51 => { + 52 => { wire__crate__api__groups__get_group_information_impl(port, ptr, rust_vec_len, data_len) } - 52 => wire__crate__api__groups__get_groups_informations_impl( + 53 => wire__crate__api__groups__get_groups_informations_impl( port, ptr, rust_vec_len, data_len, ), - 53 => wire__crate__api__notifications__get_push_registration_impl( + 54 => wire__crate__api__notifications__get_push_registration_impl( port, ptr, rust_vec_len, data_len, ), - 54 => { + 55 => { wire__crate__api__groups__get_ratchet_tree_info_impl(port, ptr, rust_vec_len, data_len) } - 55 => wire__crate__api__users__get_user_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__groups__group_admins_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__groups__group_group_type_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__utils__group_id_from_string_impl(port, ptr, rust_vec_len, data_len), - 59 => wire__crate__api__utils__group_id_to_string_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__groups__group_is_direct_message_type_impl( + 56 => wire__crate__api__users__get_user_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__api__groups__group_admins_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__groups__group_group_type_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__utils__group_id_from_string_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__utils__group_id_to_string_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__groups__group_is_direct_message_type_impl( port, ptr, rust_vec_len, data_len, ), - 61 => wire__crate__api__groups__group_is_group_type_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__groups__group_members_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__groups__group_required_proposals_impl( + 62 => wire__crate__api__groups__group_is_group_type_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__groups__group_members_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__groups__group_required_proposals_impl( port, ptr, rust_vec_len, data_len, ), - 64 => wire__crate__api__groups__group_update_group_data_impl( + 65 => wire__crate__api__groups__group_update_group_data_impl( port, ptr, rust_vec_len, data_len, ), - 66 => wire__crate__api__initialize_whitenoise_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__accounts__is_following_user_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__mute_list__is_user_blocked_impl(port, ptr, rust_vec_len, data_len), - 81 => { + 67 => wire__crate__api__initialize_whitenoise_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__accounts__is_following_user_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__mute_list__is_user_blocked_impl(port, ptr, rust_vec_len, data_len), + 82 => { wire__crate__api__groups__leave_and_delete_group_impl(port, ptr, rust_vec_len, data_len) } - 82 => wire__crate__api__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__drafts__load_draft_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__accounts__login_cancel_impl(port, ptr, rust_vec_len, data_len), - 85 => wire__crate__api__signer__login_external_signer_publish_default_relays_impl( + 83 => wire__crate__api__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__drafts__load_draft_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__accounts__login_cancel_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__signer__login_external_signer_publish_default_relays_impl( port, ptr, rust_vec_len, data_len, ), - 86 => wire__crate__api__signer__login_external_signer_start_impl( + 87 => wire__crate__api__signer__login_external_signer_start_impl( port, ptr, rust_vec_len, data_len, ), - 87 => wire__crate__api__signer__login_external_signer_with_custom_relay_impl( + 88 => wire__crate__api__signer__login_external_signer_with_custom_relay_impl( port, ptr, rust_vec_len, data_len, ), - 88 => wire__crate__api__accounts__login_publish_default_relays_impl( + 89 => wire__crate__api__accounts__login_publish_default_relays_impl( port, ptr, rust_vec_len, data_len, ), - 89 => wire__crate__api__accounts__login_start_impl(port, ptr, rust_vec_len, data_len), - 90 => wire__crate__api__accounts__login_with_custom_relay_impl( + 90 => wire__crate__api__accounts__login_start_impl(port, ptr, rust_vec_len, data_len), + 91 => wire__crate__api__accounts__login_with_custom_relay_impl( port, ptr, rust_vec_len, data_len, ), - 91 => wire__crate__api__accounts__logout_impl(port, ptr, rust_vec_len, data_len), - 92 => wire__crate__api__account_groups__mark_message_read_impl( + 92 => wire__crate__api__accounts__logout_impl(port, ptr, rust_vec_len, data_len), + 93 => wire__crate__api__account_groups__mark_message_read_impl( port, ptr, rust_vec_len, data_len, ), - 93 => wire__crate__api__chat_list__mute_chat_impl(port, ptr, rust_vec_len, data_len), - 95 => wire__crate__api__accounts__publish_account_key_package_impl( + 94 => wire__crate__api__chat_list__mute_chat_impl(port, ptr, rust_vec_len, data_len), + 96 => wire__crate__api__product_analytics__product_analytics_consent_version_impl( port, ptr, rust_vec_len, data_len, ), - 96 => wire__crate__api__signer__register_external_signer_impl( + 97 => wire__crate__api__product_analytics__product_analytics_settings_impl( port, ptr, rust_vec_len, data_len, ), - 97 => wire__crate__api__relays__relay_type_inbox_impl(port, ptr, rust_vec_len, data_len), - 98 => { + 98 => wire__crate__api__accounts__publish_account_key_package_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 99 => wire__crate__api__signer__register_external_signer_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 100 => wire__crate__api__relays__relay_type_inbox_impl(port, ptr, rust_vec_len, data_len), + 101 => { wire__crate__api__relays__relay_type_key_package_impl(port, ptr, rust_vec_len, data_len) } - 99 => wire__crate__api__relays__relay_type_nip65_impl(port, ptr, rust_vec_len, data_len), - 100 => { + 102 => wire__crate__api__relays__relay_type_nip65_impl(port, ptr, rust_vec_len, data_len), + 103 => { wire__crate__api__utils__relay_url_from_string_impl(port, ptr, rust_vec_len, data_len) } - 101 => { + 104 => { wire__crate__api__accounts__remove_account_relay_impl(port, ptr, rust_vec_len, data_len) } - 102 => wire__crate__api__groups__remove_members_from_group_impl( + 105 => wire__crate__api__groups__remove_members_from_group_impl( port, ptr, rust_vec_len, data_len, ), - 103 => wire__crate__api__accounts__restore_default_relays_impl( + 106 => wire__crate__api__accounts__restore_default_relays_impl( port, ptr, rust_vec_len, data_len, ), - 104 => wire__crate__api__messages__retry_message_publish_impl( + 107 => wire__crate__api__messages__retry_message_publish_impl( port, ptr, rust_vec_len, data_len, ), - 105 => wire__crate__api__drafts__save_draft_impl(port, ptr, rust_vec_len, data_len), - 106 => wire__crate__api__messages__search_messages_impl(port, ptr, rust_vec_len, data_len), - 107 => wire__crate__api__messages__search_messages_in_group_impl( + 108 => wire__crate__api__drafts__save_draft_impl(port, ptr, rust_vec_len, data_len), + 109 => wire__crate__api__messages__search_messages_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__api__messages__search_messages_in_group_impl( port, ptr, rust_vec_len, data_len, ), - 108 => wire__crate__api__user_search__search_users_impl(port, ptr, rust_vec_len, data_len), - 109 => wire__crate__api__groups__self_demote_impl(port, ptr, rust_vec_len, data_len), - 110 => { + 111 => wire__crate__api__user_search__search_users_impl(port, ptr, rust_vec_len, data_len), + 112 => wire__crate__api__groups__self_demote_impl(port, ptr, rust_vec_len, data_len), + 113 => { wire__crate__api__bug_report__send_bug_report_impl(port, ptr, rust_vec_len, data_len) } - 111 => wire__crate__api__messages__send_message_to_group_impl( + 114 => wire__crate__api__messages__send_message_to_group_impl( port, ptr, rust_vec_len, data_len, ), - 112 => { + 115 => { wire__crate__api__chat_list__set_chat_pin_order_impl(port, ptr, rust_vec_len, data_len) } - 113 => { + 116 => wire__crate__api__product_analytics__set_product_analytics_enabled_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 117 => { wire__crate__api__utils__string_from_relay_url_impl(port, ptr, rust_vec_len, data_len) } - 114 => wire__crate__api__chat_list__subscribe_to_archived_chat_list_impl( + 118 => wire__crate__api__chat_list__subscribe_to_archived_chat_list_impl( port, ptr, rust_vec_len, data_len, ), - 115 => wire__crate__api__chat_list__subscribe_to_chat_list_impl( + 119 => wire__crate__api__chat_list__subscribe_to_chat_list_impl( port, ptr, rust_vec_len, data_len, ), - 116 => wire__crate__api__messages__subscribe_to_group_messages_impl( + 120 => wire__crate__api__messages__subscribe_to_group_messages_impl( port, ptr, rust_vec_len, data_len, ), - 117 => wire__crate__api__group_state__subscribe_to_group_state_impl( + 121 => wire__crate__api__group_state__subscribe_to_group_state_impl( port, ptr, rust_vec_len, data_len, ), - 118 => wire__crate__api__notifications__subscribe_to_notifications_impl( + 122 => wire__crate__api__notifications__subscribe_to_notifications_impl( port, ptr, rust_vec_len, data_len, ), - 119 => { + 123 => { wire__crate__api__logs__subscribe_to_rust_logs_impl(port, ptr, rust_vec_len, data_len) } - 120 => wire__crate__api__users__subscribe_to_user_impl(port, ptr, rust_vec_len, data_len), - 121 => wire__crate__api__utils__tag_from_vec_impl(port, ptr, rust_vec_len, data_len), - 126 => { + 124 => wire__crate__api__users__subscribe_to_user_impl(port, ptr, rust_vec_len, data_len), + 125 => wire__crate__api__utils__tag_from_vec_impl(port, ptr, rust_vec_len, data_len), + 130 => wire__crate__api__product_analytics__track_product_analytics_event_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 131 => { wire__crate__api__account_groups__unarchive_chat_impl(port, ptr, rust_vec_len, data_len) } - 127 => wire__crate__api__mute_list__unblock_user_impl(port, ptr, rust_vec_len, data_len), - 128 => wire__crate__api__accounts__unfollow_user_impl(port, ptr, rust_vec_len, data_len), - 129 => wire__crate__api__chat_list__unmute_chat_impl(port, ptr, rust_vec_len, data_len), - 130 => wire__crate__api__accounts__update_account_metadata_impl( + 132 => wire__crate__api__mute_list__unblock_user_impl(port, ptr, rust_vec_len, data_len), + 133 => wire__crate__api__accounts__unfollow_user_impl(port, ptr, rust_vec_len, data_len), + 134 => wire__crate__api__chat_list__unmute_chat_impl(port, ptr, rust_vec_len, data_len), + 135 => wire__crate__api__accounts__update_account_metadata_impl( port, ptr, rust_vec_len, data_len, ), - 131 => wire__crate__api__update_language_impl(port, ptr, rust_vec_len, data_len), - 132 => wire__crate__api__accounts__update_notifications_enabled_impl( + 136 => wire__crate__api__update_language_impl(port, ptr, rust_vec_len, data_len), + 137 => wire__crate__api__accounts__update_notifications_enabled_impl( port, ptr, rust_vec_len, data_len, ), - 133 => wire__crate__api__update_theme_mode_impl(port, ptr, rust_vec_len, data_len), - 134 => wire__crate__api__accounts__upload_account_profile_picture_impl( + 138 => wire__crate__api__update_theme_mode_impl(port, ptr, rust_vec_len, data_len), + 139 => wire__crate__api__accounts__upload_account_profile_picture_impl( port, ptr, rust_vec_len, data_len, ), - 135 => { + 140 => { wire__crate__api__media_files__upload_chat_media_impl(port, ptr, rust_vec_len, data_len) } - 136 => wire__crate__api__groups__upload_group_image_impl(port, ptr, rust_vec_len, data_len), - 137 => wire__crate__api__notifications__upsert_push_registration_impl( + 141 => wire__crate__api__groups__upload_group_image_impl(port, ptr, rust_vec_len, data_len), + 142 => wire__crate__api__notifications__upsert_push_registration_impl( port, ptr, rust_vec_len, data_len, ), - 138 => { + 143 => { wire__crate__api__users__user_has_key_package_impl(port, ptr, rust_vec_len, data_len) } - 139 => wire__crate__api__users__user_metadata_impl(port, ptr, rust_vec_len, data_len), - 140 => wire__crate__api__users__user_relays_impl(port, ptr, rust_vec_len, data_len), - 141 => wire__crate__api__groups__visible_groups_with_info_impl( + 144 => wire__crate__api__users__user_metadata_impl(port, ptr, rust_vec_len, data_len), + 145 => wire__crate__api__users__user_relays_impl(port, ptr, rust_vec_len, data_len), + 146 => wire__crate__api__groups__visible_groups_with_info_impl( port, ptr, rust_vec_len, @@ -8232,28 +8738,28 @@ fn pde_ffi_dispatcher_sync_impl( wire__crate__api__relay_defaults__default_relay_urls_impl(ptr, rust_vec_len, data_len) } 33 => wire__crate__api__utils__event_id_to_nevent_uri_impl(ptr, rust_vec_len, data_len), - 65 => wire__crate__api__utils__hex_pubkey_from_npub_impl(ptr, rust_vec_len, data_len), - 69 => { + 66 => wire__crate__api__utils__hex_pubkey_from_npub_impl(ptr, rust_vec_len, data_len), + 70 => { wire__crate__api__utils__language_chinese_simplified_impl(ptr, rust_vec_len, data_len) } - 70 => { + 71 => { wire__crate__api__utils__language_chinese_traditional_impl(ptr, rust_vec_len, data_len) } - 71 => wire__crate__api__utils__language_english_impl(ptr, rust_vec_len, data_len), - 72 => wire__crate__api__utils__language_french_impl(ptr, rust_vec_len, data_len), - 73 => wire__crate__api__utils__language_german_impl(ptr, rust_vec_len, data_len), - 74 => wire__crate__api__utils__language_italian_impl(ptr, rust_vec_len, data_len), - 75 => wire__crate__api__utils__language_portuguese_impl(ptr, rust_vec_len, data_len), - 76 => wire__crate__api__utils__language_russian_impl(ptr, rust_vec_len, data_len), - 77 => wire__crate__api__utils__language_spanish_impl(ptr, rust_vec_len, data_len), - 78 => wire__crate__api__utils__language_system_impl(ptr, rust_vec_len, data_len), - 79 => wire__crate__api__utils__language_to_string_impl(ptr, rust_vec_len, data_len), - 80 => wire__crate__api__utils__language_turkish_impl(ptr, rust_vec_len, data_len), - 94 => wire__crate__api__utils__npub_from_hex_pubkey_impl(ptr, rust_vec_len, data_len), - 122 => wire__crate__api__utils__theme_mode_dark_impl(ptr, rust_vec_len, data_len), - 123 => wire__crate__api__utils__theme_mode_light_impl(ptr, rust_vec_len, data_len), - 124 => wire__crate__api__utils__theme_mode_system_impl(ptr, rust_vec_len, data_len), - 125 => wire__crate__api__utils__theme_mode_to_string_impl(ptr, rust_vec_len, data_len), + 72 => wire__crate__api__utils__language_english_impl(ptr, rust_vec_len, data_len), + 73 => wire__crate__api__utils__language_french_impl(ptr, rust_vec_len, data_len), + 74 => wire__crate__api__utils__language_german_impl(ptr, rust_vec_len, data_len), + 75 => wire__crate__api__utils__language_italian_impl(ptr, rust_vec_len, data_len), + 76 => wire__crate__api__utils__language_portuguese_impl(ptr, rust_vec_len, data_len), + 77 => wire__crate__api__utils__language_russian_impl(ptr, rust_vec_len, data_len), + 78 => wire__crate__api__utils__language_spanish_impl(ptr, rust_vec_len, data_len), + 79 => wire__crate__api__utils__language_system_impl(ptr, rust_vec_len, data_len), + 80 => wire__crate__api__utils__language_to_string_impl(ptr, rust_vec_len, data_len), + 81 => wire__crate__api__utils__language_turkish_impl(ptr, rust_vec_len, data_len), + 95 => wire__crate__api__utils__npub_from_hex_pubkey_impl(ptr, rust_vec_len, data_len), + 126 => wire__crate__api__utils__theme_mode_dark_impl(ptr, rust_vec_len, data_len), + 127 => wire__crate__api__utils__theme_mode_light_impl(ptr, rust_vec_len, data_len), + 128 => wire__crate__api__utils__theme_mode_system_impl(ptr, rust_vec_len, data_len), + 129 => wire__crate__api__utils__theme_mode_to_string_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8540,6 +9046,27 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::AptabaseAnalyticsConfig { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.app_key.into_into_dart().into_dart(), + self.host.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::AptabaseAnalyticsConfig +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::AptabaseAnalyticsConfig +{ + fn into_into_dart(self) -> crate::api::product_analytics::AptabaseAnalyticsConfig { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::chat_list::ChatListStreamItem { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -8817,6 +9344,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::media_files::FileMetadata { self.dimensions.into_into_dart().into_dart(), self.blurhash.into_into_dart().into_dart(), self.thumbhash.into_into_dart().into_dart(), + self.duration_ms.into_into_dart().into_dart(), + self.waveform.into_into_dart().into_dart(), ] .into_dart() } @@ -8868,6 +9397,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::groups::FlutterGroupDataUpdat self.image_key.into_into_dart().into_dart(), self.image_hash.into_into_dart().into_dart(), self.image_nonce.into_into_dart().into_dart(), + self.disappearing_message_secs.into_into_dart().into_dart(), ] .into_dart() } @@ -9396,6 +9926,259 @@ impl flutter_rust_bridge::IntoIntoDart flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::product_analytics::ProductAnalyticsBackend::Disabled => { + [0.into_dart()].into_dart() + } + crate::api::product_analytics::ProductAnalyticsBackend::Aptabase { config } => { + [1.into_dart(), config.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsBackend +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsBackend +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsBackend { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsConfig { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.backend.into_into_dart().into_dart(), + self.app_version.into_into_dart().into_dart(), + self.bundle_identifier.into_into_dart().into_dart(), + self.device_class.into_into_dart().into_dart(), + self.os_name.into_into_dart().into_dart(), + self.locale.into_into_dart().into_dart(), + self.is_debug.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsConfig +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsConfig +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsConfig { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsDeviceClass { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Phone => 0.into_dart(), + Self::Tablet => 1.into_dart(), + Self::Desktop => 2.into_dart(), + Self::Unknown => 3.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsDeviceClass +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsDeviceClass +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsDeviceClass { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.name.into_into_dart().into_dart(), + self.string_props.into_into_dart().into_dart(), + self.number_props.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsEvent +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsEvent +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsEventName { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::AnalyticsEnabled => 0.into_dart(), + Self::AppStarted => 1.into_dart(), + Self::AppForegrounded => 2.into_dart(), + Self::AppBackgrounded => 3.into_dart(), + Self::OnboardingStarted => 4.into_dart(), + Self::OnboardingCompleted => 5.into_dart(), + Self::IdentityCreated => 6.into_dart(), + Self::LoginStarted => 7.into_dart(), + Self::LoginCompleted => 8.into_dart(), + Self::LoginFailed => 9.into_dart(), + Self::MessageSendStarted => 10.into_dart(), + Self::MessageSendCompleted => 11.into_dart(), + Self::MessageSendFailed => 12.into_dart(), + Self::GroupCreateStarted => 13.into_dart(), + Self::GroupCreateCompleted => 14.into_dart(), + Self::GroupCreateFailed => 15.into_dart(), + Self::MembersAdded => 16.into_dart(), + Self::MembersRemoved => 17.into_dart(), + Self::GroupDataUpdated => 18.into_dart(), + Self::MediaUploadStarted => 19.into_dart(), + Self::MediaUploadCompleted => 20.into_dart(), + Self::MediaUploadFailed => 21.into_dart(), + Self::PushRegistrationCompleted => 22.into_dart(), + Self::PushRegistrationFailed => 23.into_dart(), + Self::SettingChanged => 24.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsEventName +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsEventName +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsEventName { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsFlushStatus { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Flushed => 0.into_dart(), + Self::NothingToFlush => 1.into_dart(), + Self::Disabled => 2.into_dart(), + Self::Unconfigured => 3.into_dart(), + Self::TimedOut => 4.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsFlushStatus +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsFlushStatus +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsFlushStatus { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsNumberProp { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.key.into_into_dart().into_dart(), + self.value.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsNumberProp +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsNumberProp +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsNumberProp { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsSettings { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.enabled.into_into_dart().into_dart(), + self.created_at.into_into_dart().into_dart(), + self.updated_at.into_into_dart().into_dart(), + self.consent_version.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsSettings +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsSettings +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsSettings { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsStringProp { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.key.into_into_dart().into_dart(), + self.value.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsStringProp +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsStringProp +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsStringProp { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::product_analytics::ProductAnalyticsTrackStatus { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Queued => 0.into_dart(), + Self::IgnoredDisabled => 1.into_dart(), + Self::IgnoredUnconfigured => 2.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::product_analytics::ProductAnalyticsTrackStatus +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::product_analytics::ProductAnalyticsTrackStatus +{ + fn into_into_dart(self) -> crate::api::product_analytics::ProductAnalyticsTrackStatus { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::notifications::PushPlatform { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -9827,6 +10610,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::WhitenoiseConfig { self.data_dir.into_into_dart().into_dart(), self.logs_dir.into_into_dart().into_dart(), self.default_relay_urls.into_into_dart().into_dart(), + self.product_analytics_config.into_into_dart().into_dart(), ] .into_dart() } @@ -10227,6 +11011,14 @@ impl SseEncode for crate::api::error::ApiError { } } +impl SseEncode for crate::api::product_analytics::AptabaseAnalyticsConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.app_key, serializer); + ::sse_encode(self.host, serializer); + } +} + impl SseEncode for bool { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -10421,6 +11213,13 @@ impl SseEncode for crate::api::messages::EmojiReaction { } } +impl SseEncode for f64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_f64::(self).unwrap(); + } +} + impl SseEncode for crate::api::media_files::FileMetadata { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -10428,6 +11227,8 @@ impl SseEncode for crate::api::media_files::FileMetadata { >::sse_encode(self.dimensions, serializer); >::sse_encode(self.blurhash, serializer); >::sse_encode(self.thumbhash, serializer); + >::sse_encode(self.duration_ms, serializer); + >>::sse_encode(self.waveform, serializer); } } @@ -10453,6 +11254,7 @@ impl SseEncode for crate::api::groups::FlutterGroupDataUpdate { >::sse_encode(self.image_key, serializer); >::sse_encode(self.image_hash, serializer); >::sse_encode(self.image_nonce, serializer); + >::sse_encode(self.disappearing_message_secs, serializer); } } @@ -10796,6 +11598,30 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode( + item, serializer, + ); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode( + item, serializer, + ); + } + } +} + impl SseEncode for Vec<(String, String)> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -11143,6 +11969,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -11163,6 +11999,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -11183,6 +12029,16 @@ impl SseEncode for Option> { } } +impl SseEncode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + >::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option<[u8; 12]> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -11203,6 +12059,176 @@ impl SseEncode for Option<[u8; 32]> { } } +impl SseEncode for crate::api::product_analytics::ProductAnalyticsBackend { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::product_analytics::ProductAnalyticsBackend::Disabled => { + ::sse_encode(0, serializer); + } + crate::api::product_analytics::ProductAnalyticsBackend::Aptabase { config } => { + ::sse_encode(1, serializer); + ::sse_encode( + config, serializer, + ); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsConfig { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + self.backend, + serializer, + ); + ::sse_encode(self.app_version, serializer); + ::sse_encode(self.bundle_identifier, serializer); + ::sse_encode( + self.device_class, + serializer, + ); + ::sse_encode(self.os_name, serializer); + ::sse_encode(self.locale, serializer); + ::sse_encode(self.is_debug, serializer); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsDeviceClass { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::product_analytics::ProductAnalyticsDeviceClass::Phone => 0, + crate::api::product_analytics::ProductAnalyticsDeviceClass::Tablet => 1, + crate::api::product_analytics::ProductAnalyticsDeviceClass::Desktop => 2, + crate::api::product_analytics::ProductAnalyticsDeviceClass::Unknown => 3, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + self.name, serializer, + ); + >::sse_encode( + self.string_props, + serializer, + ); + >::sse_encode( + self.number_props, + serializer, + ); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsEventName { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(match self {crate::api::product_analytics::ProductAnalyticsEventName::AnalyticsEnabled => { 0 } +crate::api::product_analytics::ProductAnalyticsEventName::AppStarted => { 1 } +crate::api::product_analytics::ProductAnalyticsEventName::AppForegrounded => { 2 } +crate::api::product_analytics::ProductAnalyticsEventName::AppBackgrounded => { 3 } +crate::api::product_analytics::ProductAnalyticsEventName::OnboardingStarted => { 4 } +crate::api::product_analytics::ProductAnalyticsEventName::OnboardingCompleted => { 5 } +crate::api::product_analytics::ProductAnalyticsEventName::IdentityCreated => { 6 } +crate::api::product_analytics::ProductAnalyticsEventName::LoginStarted => { 7 } +crate::api::product_analytics::ProductAnalyticsEventName::LoginCompleted => { 8 } +crate::api::product_analytics::ProductAnalyticsEventName::LoginFailed => { 9 } +crate::api::product_analytics::ProductAnalyticsEventName::MessageSendStarted => { 10 } +crate::api::product_analytics::ProductAnalyticsEventName::MessageSendCompleted => { 11 } +crate::api::product_analytics::ProductAnalyticsEventName::MessageSendFailed => { 12 } +crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateStarted => { 13 } +crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateCompleted => { 14 } +crate::api::product_analytics::ProductAnalyticsEventName::GroupCreateFailed => { 15 } +crate::api::product_analytics::ProductAnalyticsEventName::MembersAdded => { 16 } +crate::api::product_analytics::ProductAnalyticsEventName::MembersRemoved => { 17 } +crate::api::product_analytics::ProductAnalyticsEventName::GroupDataUpdated => { 18 } +crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadStarted => { 19 } +crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadCompleted => { 20 } +crate::api::product_analytics::ProductAnalyticsEventName::MediaUploadFailed => { 21 } +crate::api::product_analytics::ProductAnalyticsEventName::PushRegistrationCompleted => { 22 } +crate::api::product_analytics::ProductAnalyticsEventName::PushRegistrationFailed => { 23 } +crate::api::product_analytics::ProductAnalyticsEventName::SettingChanged => { 24 } + _ => { unimplemented!(""); }}, serializer); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsFlushStatus { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::product_analytics::ProductAnalyticsFlushStatus::Flushed => 0, + crate::api::product_analytics::ProductAnalyticsFlushStatus::NothingToFlush => 1, + crate::api::product_analytics::ProductAnalyticsFlushStatus::Disabled => 2, + crate::api::product_analytics::ProductAnalyticsFlushStatus::Unconfigured => 3, + crate::api::product_analytics::ProductAnalyticsFlushStatus::TimedOut => 4, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsNumberProp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.key, serializer); + ::sse_encode(self.value, serializer); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsSettings { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.enabled, serializer); + >::sse_encode(self.created_at, serializer); + >::sse_encode(self.updated_at, serializer); + ::sse_encode(self.consent_version, serializer); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsStringProp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.key, serializer); + ::sse_encode(self.value, serializer); + } +} + +impl SseEncode for crate::api::product_analytics::ProductAnalyticsTrackStatus { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::product_analytics::ProductAnalyticsTrackStatus::Queued => 0, + crate::api::product_analytics::ProductAnalyticsTrackStatus::IgnoredDisabled => 1, + crate::api::product_analytics::ProductAnalyticsTrackStatus::IgnoredUnconfigured => { + 2 + } + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + impl SseEncode for crate::api::notifications::PushPlatform { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -11531,6 +12557,10 @@ impl SseEncode for crate::api::WhitenoiseConfig { ::sse_encode(self.data_dir, serializer); ::sse_encode(self.logs_dir, serializer); >>::sse_encode(self.default_relay_urls, serializer); + >::sse_encode( + self.product_analytics_config, + serializer, + ); } } diff --git a/test/main_test.dart b/test/main_test.dart index 978134068..35080a371 100644 --- a/test/main_test.dart +++ b/test/main_test.dart @@ -5,11 +5,14 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData, ProviderContainer, ProviderScope; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:whitenoise/main.dart' show WnApp, initializeAppContainer, kDataVersion, kDataVersionFile; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/providers/theme_provider.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; import 'package:whitenoise/src/rust/api.dart' as rust_api; +import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytics; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'mocks/mock_secure_storage.dart'; @@ -103,6 +106,7 @@ class _MockInitApi extends MockWnApi { required String dataDir, required String logsDir, List? defaultRelayUrls, + rust_analytics.ProductAnalyticsConfig? productAnalyticsConfig, }) async { createdConfigDataDir = dataDir; createdConfigLogsDir = logsDir; @@ -110,6 +114,7 @@ class _MockInitApi extends MockWnApi { dataDir: dataDir, logsDir: logsDir, defaultRelayUrls: defaultRelayUrls, + productAnalyticsConfig: productAnalyticsConfig, ); } @@ -131,12 +136,54 @@ class _MockInitApi extends MockWnApi { } } +class _AnalyticsRecorder { + final events = []; + int flushCount = 0; + + ProductAnalyticsService service() { + return ProductAnalyticsService( + readSettings: () async => _settings(), + setEnabled: ({required enabled, required consentVersion}) async => + _settings(enabled: enabled, consentVersion: consentVersion), + track: ({required event}) async { + events.add(event.name); + return rust_analytics.ProductAnalyticsTrackStatus.queued; + }, + flush: () async { + flushCount++; + return rust_analytics.ProductAnalyticsFlushStatus.flushed; + }, + consentVersion: () async => 'test-consent-version', + ); + } + + rust_analytics.ProductAnalyticsSettings _settings({ + bool enabled = false, + String consentVersion = 'test-consent-version', + }) { + final now = DateTime(2026); + return rust_analytics.ProductAnalyticsSettings( + enabled: enabled, + createdAt: now, + updatedAt: now, + consentVersion: consentVersion, + ); + } +} + void main() { late _MockInitApi mockApi; setUpAll(() { mockApi = _MockInitApi(); RustLib.initMock(api: mockApi); + PackageInfo.setMockInitialValues( + appName: 'White Noise', + packageName: 'org.parres.whitenoise.staging', + version: '1.2.3', + buildNumber: '45', + buildSignature: '', + ); }); setUp(() { @@ -146,17 +193,20 @@ void main() { group('WnApp', () { late _MockAuthNotifier mockAuth; late _MockThemeNotifier mockTheme; + late _AnalyticsRecorder analytics; Future pumpWnApp(WidgetTester tester) async { setUpTestView(tester); mockAuth = _MockAuthNotifier(); mockTheme = _MockThemeNotifier(); + analytics = _AnalyticsRecorder(); await tester.pumpWidget( ProviderScope( overrides: [ authProvider.overrideWith(() => mockAuth), themeProvider.overrideWith(() => mockTheme), secureStorageProvider.overrideWithValue(MockSecureStorage()), + productAnalyticsServiceProvider.overrideWithValue(analytics.service()), ], child: const WnApp(), ), @@ -207,6 +257,12 @@ void main() { await tester.pumpAndSettle(); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); + + expect(analytics.events, [ + rust_analytics.ProductAnalyticsEventName.appBackgrounded, + rust_analytics.ProductAnalyticsEventName.appForegrounded, + ]); + expect(analytics.flushCount, 1); }); testWidgets('reconciles external signer callbacks on app resume', (tester) async { @@ -272,6 +328,16 @@ void main() { expect(mockApi.initializedConfig, isNotNull); }); + test('passes product analytics config into Rust initialization', () async { + await initializeAppContainer(); + + final analyticsConfig = mockApi.initializedConfig?.productAnalyticsConfig; + expect(analyticsConfig, isNotNull); + expect(analyticsConfig?.appVersion, '1.2.3+45'); + expect(analyticsConfig?.bundleIdentifier, 'org.parres.whitenoise.staging'); + expect(analyticsConfig?.backend, const rust_analytics.ProductAnalyticsBackend.disabled()); + }); + test('returns a ProviderContainer', () async { final container = await initializeAppContainer(); diff --git a/test/mocks/mock_secure_storage.dart b/test/mocks/mock_secure_storage.dart index 6c5dd8b3e..7967d8fc6 100644 --- a/test/mocks/mock_secure_storage.dart +++ b/test/mocks/mock_secure_storage.dart @@ -3,6 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class MockSecureStorage implements FlutterSecureStorage { final Map _data = {}; bool shouldThrowOnRead = false; + bool shouldThrowOnWrite = false; @override Future read({ @@ -30,7 +31,12 @@ class MockSecureStorage implements FlutterSecureStorage { WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, - }) async => _data[key] = value!; + }) async { + if (shouldThrowOnWrite) { + throw Exception('Secure storage write error'); + } + _data[key] = value!; + } @override Future delete({ diff --git a/test/mocks/mock_wn_api.dart b/test/mocks/mock_wn_api.dart index f0b98f664..5fd7b1639 100644 --- a/test/mocks/mock_wn_api.dart +++ b/test/mocks/mock_wn_api.dart @@ -13,6 +13,7 @@ import 'package:whitenoise/src/rust/api/media_files.dart'; import 'package:whitenoise/src/rust/api/messages.dart'; import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/src/rust/api/mute_list.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart'; import 'package:whitenoise/src/rust/api/user_search.dart'; import 'package:whitenoise/src/rust/api/users.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; @@ -105,6 +106,15 @@ class MockWnApi implements RustLibApi { bool mockNotificationsEnabled = true; bool shouldFailAccountSettings = false; bool shouldFailUpdateNotificationsEnabled = false; + bool productAnalyticsEnabled = false; + String productAnalyticsConsentVersion = 'test-consent-version'; + final productAnalyticsSetEnabledCalls = []; + final productAnalyticsEvents = []; + int productAnalyticsFlushCallCount = 0; + bool shouldFailProductAnalyticsSettings = false; + bool shouldFailSetProductAnalyticsEnabled = false; + bool shouldFailTrackProductAnalyticsEvent = false; + bool shouldFailFlushProductAnalytics = false; @override Future crateApiUsersUserHasKeyPackage({ @@ -919,6 +929,68 @@ class MockWnApi implements RustLibApi { return AccountSettings(notificationsEnabled: mockNotificationsEnabled); } + @override + Future crateApiProductAnalyticsProductAnalyticsConsentVersion() async { + return productAnalyticsConsentVersion; + } + + @override + Future crateApiProductAnalyticsProductAnalyticsSettings() async { + if (shouldFailProductAnalyticsSettings) { + throw Exception('Failed to read product analytics settings'); + } + return _productAnalyticsSettings(); + } + + @override + Future crateApiProductAnalyticsSetProductAnalyticsEnabled({ + required bool enabled, + required String consentVersion, + }) async { + productAnalyticsSetEnabledCalls.add(enabled); + if (shouldFailSetProductAnalyticsEnabled) { + throw Exception('Failed to update product analytics consent'); + } + productAnalyticsEnabled = enabled; + productAnalyticsConsentVersion = consentVersion; + return _productAnalyticsSettings(); + } + + @override + Future crateApiProductAnalyticsTrackProductAnalyticsEvent({ + required ProductAnalyticsEvent event, + }) async { + if (shouldFailTrackProductAnalyticsEvent) { + throw Exception('Failed to track product analytics event'); + } + if (!productAnalyticsEnabled) { + return ProductAnalyticsTrackStatus.ignoredDisabled; + } + productAnalyticsEvents.add(event); + return ProductAnalyticsTrackStatus.queued; + } + + @override + Future crateApiProductAnalyticsFlushProductAnalytics() async { + productAnalyticsFlushCallCount++; + if (shouldFailFlushProductAnalytics) { + throw Exception('Failed to flush product analytics'); + } + return productAnalyticsEnabled + ? ProductAnalyticsFlushStatus.flushed + : ProductAnalyticsFlushStatus.disabled; + } + + ProductAnalyticsSettings _productAnalyticsSettings() { + final now = DateTime(2026); + return ProductAnalyticsSettings( + enabled: productAnalyticsEnabled, + createdAt: now, + updatedAt: now, + consentVersion: productAnalyticsConsentVersion, + ); + } + void reset() { sendBugReportCalled = false; lastBugReportWhatWentWrong = null; @@ -975,6 +1047,15 @@ class MockWnApi implements RustLibApi { mockNotificationsEnabled = true; shouldFailAccountSettings = false; shouldFailUpdateNotificationsEnabled = false; + productAnalyticsEnabled = false; + productAnalyticsConsentVersion = 'test-consent-version'; + productAnalyticsSetEnabledCalls.clear(); + productAnalyticsEvents.clear(); + productAnalyticsFlushCallCount = 0; + shouldFailProductAnalyticsSettings = false; + shouldFailSetProductAnalyticsEnabled = false; + shouldFailTrackProductAnalyticsEvent = false; + shouldFailFlushProductAnalytics = false; zapstoreVersion = null; zapstoreShouldThrow = false; shouldFailArchiveChat = false; diff --git a/test/providers/analytics_onboarding_provider_test.dart b/test/providers/analytics_onboarding_provider_test.dart new file mode 100644 index 000000000..59e1e39d9 --- /dev/null +++ b/test/providers/analytics_onboarding_provider_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/providers/analytics_onboarding_provider.dart'; +import 'package:whitenoise/providers/auth_provider.dart'; + +import '../mocks/mock_secure_storage.dart'; +import '../test_helpers.dart'; + +void main() { + group('AnalyticsOnboardingResolutionService', () { + test('defaults to unresolved', () async { + final service = AnalyticsOnboardingResolutionService(MockSecureStorage()); + + final resolved = await service.isResolved(testPubkeyA); + + expect(resolved, isFalse); + }); + + test('persists resolution per account', () async { + final storage = MockSecureStorage(); + final service = AnalyticsOnboardingResolutionService(storage); + + await service.markResolved(testPubkeyA); + + expect(await service.isResolved(testPubkeyA), isTrue); + expect(await service.isResolved(testPubkeyB), isFalse); + }); + + test('returns unresolved when storage read fails', () async { + final storage = MockSecureStorage()..shouldThrowOnRead = true; + final service = AnalyticsOnboardingResolutionService(storage); + + final resolved = await service.isResolved(testPubkeyA); + + expect(resolved, isFalse); + }); + + test('does not throw when storage write fails', () async { + final storage = MockSecureStorage()..shouldThrowOnWrite = true; + final service = AnalyticsOnboardingResolutionService(storage); + + await service.markResolved(testPubkeyA); + + storage.shouldThrowOnWrite = false; + expect(await service.isResolved(testPubkeyA), isFalse); + }); + + test('provider reads persisted resolution from secure storage', () async { + final storage = MockSecureStorage(); + final container = ProviderContainer( + overrides: [secureStorageProvider.overrideWithValue(storage)], + ); + addTearDown(container.dispose); + + await container.read(analyticsOnboardingResolutionServiceProvider).markResolved(testPubkeyA); + + final resolved = await container.read( + analyticsOnboardingResolvedProvider(testPubkeyA).future, + ); + + expect(resolved, isTrue); + }); + }); +} diff --git a/test/screens/chat_list_screen_test.dart b/test/screens/chat_list_screen_test.dart index c1d185ff5..0ff791104 100644 --- a/test/screens/chat_list_screen_test.dart +++ b/test/screens/chat_list_screen_test.dart @@ -19,7 +19,9 @@ import 'package:whitenoise/src/rust/api/chat_list.dart'; import 'package:whitenoise/src/rust/api/chat_summary.dart'; import 'package:whitenoise/src/rust/api/groups.dart'; import 'package:whitenoise/src/rust/api/messages.dart' show ChatMessage; +import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytics; import 'package:whitenoise/src/rust/frb_generated.dart'; +import 'package:whitenoise/theme.dart'; import 'package:whitenoise/widgets/chat_list_header.dart'; import 'package:whitenoise/widgets/chat_list_search_and_filters.dart'; import 'package:whitenoise/widgets/chat_list_tile.dart'; @@ -28,6 +30,7 @@ import 'package:whitenoise/widgets/wn_icon_button.dart'; import 'package:whitenoise/widgets/wn_slate.dart'; import 'package:whitenoise/widgets/wn_system_notice.dart'; +import '../mocks/mock_secure_storage.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; @@ -159,18 +162,27 @@ class _SwitchableAuthNotifier extends AuthNotifier { } final _api = _MockApi(); +late MockSecureStorage _secureStorage; void main() { setUpAll(() => RustLib.initMock(api: _api)); setUp(() { _api.reset(); + _secureStorage = MockSecureStorage(); _setInstalledVersion('2026.3.5'); }); - Future pumpChatListScreen(WidgetTester tester) async { + Future pumpChatListScreen( + WidgetTester tester, { + List overrides = const [], + }) async { await mountTestApp( tester, - overrides: [authProvider.overrideWith(() => _MockAuthNotifier())], + overrides: [ + secureStorageProvider.overrideWithValue(_secureStorage), + authProvider.overrideWith(() => _MockAuthNotifier()), + ...overrides, + ], ); await tester.pumpAndSettle(); } @@ -240,32 +252,55 @@ void main() { }); group('without chats', () { - testWidgets('shows welcome notice', (tester) async { + testWidgets('shows analytics prompt', (tester) async { await pumpChatListScreen(tester); expect(find.byType(WnSystemNotice), findsOneWidget); - expect(find.text('Your profile is ready'), findsOneWidget); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsOneWidget); + expect(find.text('Help improve White Noise?'), findsOneWidget); }); - testWidgets('shows welcome notice description', (tester) async { + testWidgets('shows analytics prompt description', (tester) async { await pumpChatListScreen(tester); expect( - find.textContaining('Find people'), - findsWidgets, + find.textContaining('Share anonymous usage data'), + findsOneWidget, ); }); - testWidgets('shows find people button', (tester) async { + testWidgets('shows analytics prompt actions', (tester) async { await pumpChatListScreen(tester); - expect(find.byKey(const Key('find_people_button')), findsOneWidget); + expect(find.byKey(const Key('not_now_analytics_button')), findsOneWidget); + expect(find.byKey(const Key('share_analytics_button')), findsOneWidget); }); - testWidgets('shows share profile button', (tester) async { + testWidgets('shows analytics prompt below chat list header', (tester) async { await pumpChatListScreen(tester); - expect(find.byKey(const Key('share_profile_button')), findsOneWidget); + final headerBottom = tester.getBottomLeft(find.byType(ChatListHeader)).dy; + final noticeTop = tester + .getTopLeft(find.byKey(const Key('analytics_onboarding_notice'))) + .dy; + expect(noticeTop, greaterThanOrEqualTo(headerBottom)); + }); + + testWidgets('analytics prompt uses distinct content background', (tester) async { + await pumpChatListScreen(tester); + + final container = tester.widget( + find + .descendant( + of: find.byKey(const Key('analytics_onboarding_notice')), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration! as BoxDecoration; + + expect(decoration.color, SemanticColors.light.backgroundTertiary); + expect(decoration.color, isNot(SemanticColors.light.backgroundSlate)); }); testWidgets('shows slogan in body', (tester) async { @@ -291,35 +326,169 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('tapping find people navigates to user search', (tester) async { + testWidgets('tapping Not now dismisses analytics prompt without consent call', ( + tester, + ) async { await pumpChatListScreen(tester); - await tester.tap(find.byKey(const Key('find_people_button'))); + + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); await tester.pumpAndSettle(); - expect(find.byType(UserSearchScreen), findsOneWidget); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + expect(_api.productAnalyticsSetEnabledCalls, isEmpty); + expect(_api.productAnalyticsEvents, isEmpty); }); - testWidgets('tapping share profile navigates to share profile', (tester) async { + testWidgets('tapping Not now keeps analytics prompt dismissed for same account', ( + tester, + ) async { await pumpChatListScreen(tester); - await tester.tap(find.byKey(const Key('share_profile_button'))); + + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpAndSettle(); + await pumpChatListScreen(tester); - expect(find.byType(ShareProfileScreen), findsOneWidget); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + expect(_api.productAnalyticsSetEnabledCalls, isEmpty); + expect(_api.productAnalyticsEvents, isEmpty); }); - testWidgets('dismissing welcome notice hides it', (tester) async { + testWidgets('tapping Share analytics enables consent and tracks onboarding', ( + tester, + ) async { await pumpChatListScreen(tester); - expect(find.byType(WnSystemNotice), findsOneWidget); + await tester.tap(find.byKey(const Key('share_analytics_button'))); + await tester.pumpAndSettle(); + + expect(_api.productAnalyticsSetEnabledCalls, equals([true])); + expect( + _api.productAnalyticsEvents.map((event) => event.name), + containsAll([ + rust_analytics.ProductAnalyticsEventName.onboardingStarted, + rust_analytics.ProductAnalyticsEventName.onboardingCompleted, + ]), + ); + expect( + _api.productAnalyticsEvents.map((event) => event.name), + isNot(contains(rust_analytics.ProductAnalyticsEventName.identityCreated)), + ); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + }); + + testWidgets('hides analytics prompt when consent is already enabled', (tester) async { + _api.productAnalyticsEnabled = true; + + await pumpChatListScreen(tester); + + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + }); + + testWidgets('shows welcome notice after analytics step', (tester) async { + await pumpChatListScreen(tester); + + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Your profile is ready'), findsOneWidget); + expect(find.textContaining('Find people'), findsWidgets); + expect(find.byKey(const Key('find_people_button')), findsOneWidget); + expect(find.byKey(const Key('share_profile_button')), findsOneWidget); + }); + + testWidgets('welcome notice actions are side by side', (tester) async { + await pumpChatListScreen(tester); + + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + + final findPeopleTopLeft = tester.getTopLeft(find.byKey(const Key('find_people_button'))); + final shareProfileTopLeft = tester.getTopLeft( + find.byKey(const Key('share_profile_button')), + ); + + expect(shareProfileTopLeft.dx, greaterThan(findPeopleTopLeft.dx)); + expect(shareProfileTopLeft.dy, equals(findPeopleTopLeft.dy)); + }); + + testWidgets('welcome notice uses distinct content background', (tester) async { + await pumpChatListScreen(tester); + + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + + final container = tester.widget( + find + .descendant( + of: find.byKey(const Key('welcome_notice')), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration! as BoxDecoration; + + expect(decoration.color, SemanticColors.light.backgroundTertiary); + expect(decoration.color, isNot(SemanticColors.light.backgroundSlate)); + }); + + testWidgets('dismissing analytics prompt hides it', (tester) async { + await pumpChatListScreen(tester); await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); await tester.pumpAndSettle(); - expect(find.byType(WnSystemNotice), findsNothing); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + }); + + testWidgets('dismissing analytics prompt swaps to welcome notice without exit animation', ( + tester, + ) async { + await pumpChatListScreen(tester); + + await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); + await tester.pump(); + + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + }); + + testWidgets('tapping find people navigates to user search after analytics step', ( + tester, + ) async { + await pumpChatListScreen(tester); + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('find_people_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(UserSearchScreen), findsOneWidget); + }); + + testWidgets('tapping share profile navigates to share profile after analytics step', ( + tester, + ) async { + await pumpChatListScreen(tester); + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('share_profile_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(ShareProfileScreen), findsOneWidget); }); testWidgets('keeps showing slogan after dismissing notice', (tester) async { await pumpChatListScreen(tester); + await tester.tap(find.byKey(const Key('not_now_analytics_button'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); await tester.pumpAndSettle(); @@ -329,11 +498,14 @@ void main() { expect(find.text('Start a conversation'), findsNothing); }); - testWidgets('welcome notice reappears after switching accounts', (tester) async { + testWidgets('analytics prompt reappears after switching accounts', (tester) async { final mockAuth = _SwitchableAuthNotifier(); await mountTestApp( tester, - overrides: [authProvider.overrideWith(() => mockAuth)], + overrides: [ + secureStorageProvider.overrideWithValue(_secureStorage), + authProvider.overrideWith(() => mockAuth), + ], ); await tester.pumpAndSettle(); @@ -342,12 +514,24 @@ void main() { await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); + + await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); + await tester.pumpAndSettle(); + expect(find.byType(WnSystemNotice), findsNothing); mockAuth.switchTo(testPubkeyB); await tester.pumpAndSettle(); expect(find.byType(WnSystemNotice), findsOneWidget); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsOneWidget); + + mockAuth.switchTo(testPubkeyA); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); + expect(find.byKey(const Key('welcome_notice')), findsOneWidget); }); }); @@ -373,7 +557,7 @@ void main() { expect(tiles.last.key, const Key(testPubkeyB)); }); - testWidgets('hides welcome notice when chats exist', (tester) async { + testWidgets('hides analytics prompt when chats exist', (tester) async { await pumpChatListScreen(tester); expect(find.byType(WnSystemNotice), findsNothing); @@ -498,20 +682,17 @@ void main() { ); }); - testWidgets('update notice takes priority over welcome notice', (tester) async { - // No chats → welcome notice would normally show. - // Newer version available → update notice should show instead. + testWidgets('update notice takes priority over analytics prompt', (tester) async { _api.zapstoreVersion = '2026.4.0'; await pumpChatListScreen(tester); await tester.pump(); expect(find.text('Update available'), findsOneWidget); - expect(find.text('Your profile is ready'), findsNothing); + expect(find.text('Help improve White Noise?'), findsNothing); }); - testWidgets('welcome notice shown after update notice is dismissed', (tester) async { - // No chats, so welcome notice is also pending. + testWidgets('analytics prompt shown after update notice is dismissed', (tester) async { _api.zapstoreVersion = '2026.4.0'; await pumpChatListScreen(tester); @@ -520,7 +701,7 @@ void main() { await tester.tap(find.byKey(const Key('systemNotice_actionIcon'))); await tester.pumpAndSettle(); - expect(find.text('Your profile is ready'), findsOneWidget); + expect(find.text('Help improve White Noise?'), findsOneWidget); }); testWidgets('tapping Update now launches Zapstore URL with externalApplication mode', ( @@ -815,7 +996,7 @@ void main() { expect(find.byKey(const Key(testPubkeyA)), findsOneWidget); }); - testWidgets('welcome notice is not shown in archived view', (tester) async { + testWidgets('analytics prompt is not shown in archived view', (tester) async { _api.initialChats = []; await pumpChatListScreen(tester); await revealSearchHeader(tester); diff --git a/test/screens/privacy_security_screen_test.dart b/test/screens/privacy_security_screen_test.dart index a6541419e..7cd7fb7f9 100644 --- a/test/screens/privacy_security_screen_test.dart +++ b/test/screens/privacy_security_screen_test.dart @@ -5,14 +5,50 @@ import 'package:whitenoise/routes.dart'; import 'package:whitenoise/screens/chat_list_screen.dart'; import 'package:whitenoise/screens/home_screen.dart'; import 'package:whitenoise/screens/privacy_security_screen.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytics; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/wn_button.dart'; +import 'package:whitenoise/widgets/wn_toggle.dart'; import '../mocks/mock_auth_notifier.dart'; import '../mocks/mock_secure_storage.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; +class _AnalyticsRecorder { + bool enabled = false; + final events = []; + + ProductAnalyticsService service() { + return ProductAnalyticsService( + readSettings: () async => _settings(), + setEnabled: ({required enabled, required consentVersion}) async { + this.enabled = enabled; + return _settings(consentVersion: consentVersion); + }, + track: ({required event}) async { + events.add(event.name); + return rust_analytics.ProductAnalyticsTrackStatus.queued; + }, + flush: () async => rust_analytics.ProductAnalyticsFlushStatus.flushed, + consentVersion: () async => 'test-consent-version', + ); + } + + rust_analytics.ProductAnalyticsSettings _settings({ + String consentVersion = 'test-consent-version', + }) { + final now = DateTime(2026); + return rust_analytics.ProductAnalyticsSettings( + enabled: enabled, + createdAt: now, + updatedAt: now, + consentVersion: consentVersion, + ); + } +} + void main() { late MockWnApi mockApi; @@ -25,12 +61,16 @@ void main() { mockApi.reset(); }); - Future pumpPrivacySecurityScreen(WidgetTester tester) async { + Future pumpPrivacySecurityScreen( + WidgetTester tester, { + List overrides = const [], + }) async { await mountTestApp( tester, overrides: [ authProvider.overrideWith(MockAuthNotifier.new), secureStorageProvider.overrideWithValue(MockSecureStorage()), + ...overrides, ], ); Routes.pushToPrivacySecurity(tester.element(find.byType(Scaffold))); @@ -63,6 +103,41 @@ void main() { ); }); + testWidgets('displays device-local analytics consent toggle', (tester) async { + final analytics = _AnalyticsRecorder(); + await pumpPrivacySecurityScreen( + tester, + overrides: [productAnalyticsServiceProvider.overrideWithValue(analytics.service())], + ); + + final toggle = tester.widget( + find.byKey(const Key('privacy_security_analytics_consent_toggle')), + ); + expect(toggle.value, isFalse); + expect(find.text('Help improve White Noise'), findsOneWidget); + expect( + find.text( + 'Share anonymous usage data to help us find bugs and improve the app. ' + 'Messages, contacts, and keys are never included.', + ), + findsOneWidget, + ); + }); + + testWidgets('analytics toggle updates Rust consent state', (tester) async { + final analytics = _AnalyticsRecorder(); + await pumpPrivacySecurityScreen( + tester, + overrides: [productAnalyticsServiceProvider.overrideWithValue(analytics.service())], + ); + + await tester.tap(find.byKey(const Key('privacy_security_analytics_consent_toggle'))); + await tester.pumpAndSettle(); + + expect(analytics.enabled, isTrue); + expect(analytics.events, isEmpty); + }); + testWidgets('tapping delete app data shows confirmation sheet', (tester) async { await pumpPrivacySecurityScreen(tester); diff --git a/test/screens/settings_screen_test.dart b/test/screens/settings_screen_test.dart index 2f5f18542..9e5fef2df 100644 --- a/test/screens/settings_screen_test.dart +++ b/test/screens/settings_screen_test.dart @@ -316,6 +316,12 @@ void main() { expect(find.text('v1.2.3+45'), findsOneWidget); }); + testWidgets('does not display analytics consent toggle on main settings', (tester) async { + await pumpSettingsScreen(tester); + + expect(find.byKey(const Key('settings_analytics_consent_checkbox')), findsNothing); + }); + group('notification settings menu item (Android-only)', () { testWidgets('shows Notification Settings menu item on Android', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; diff --git a/test/screens/signup_screen_test.dart b/test/screens/signup_screen_test.dart index fc7c00c73..173d970e7 100644 --- a/test/screens/signup_screen_test.dart +++ b/test/screens/signup_screen_test.dart @@ -292,14 +292,23 @@ void main() { ]; }); - testWidgets('redirects to chat list on success', (tester) async { + testWidgets('redirects to chat list after profile creation', (tester) async { await pumpSignupScreen(tester, overrides: overrides); await tester.enterText(find.byType(TextField).first, 'Test User'); await tester.tap(find.text('Create profile')); await tester.pumpAndSettle(); + expect(find.byType(ChatListScreen), findsOneWidget); }); + testWidgets('does not show analytics consent controls in signup', (tester) async { + await pumpSignupScreen(tester, overrides: overrides); + await tester.enterText(find.byType(TextField).first, 'Test User'); + + expect(find.byKey(const Key('signup_analytics_consent_checkbox')), findsNothing); + expect(find.text('Help improve White Noise?'), findsNothing); + }); + testWidgets('does not redirect on failure', (tester) async { mockAuth.errorToThrow = Exception('Network error'); await pumpSignupScreen(tester, overrides: overrides); diff --git a/test/services/product_analytics_config_test.dart b/test/services/product_analytics_config_test.dart new file mode 100644 index 000000000..6a5363002 --- /dev/null +++ b/test/services/product_analytics_config_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:flutter/widgets.dart' show Size; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/services/product_analytics_config.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart'; + +void main() { + group('buildProductAnalyticsConfig', () { + test('uses staging bundle metadata with the supplied staging Aptabase key', () { + final config = buildProductAnalyticsConfig( + const ProductAnalyticsConfigInput( + appVersion: '2026.5.7+24', + bundleIdentifier: 'org.parres.whitenoise.staging', + locale: 'en-US', + platform: TargetPlatform.android, + logicalSize: Size(390, 844), + isDebug: false, + aptabaseHost: 'https://analytics.example.com', + aptabaseAppKey: 'staging-app-key', + ), + ); + + expect(config.appVersion, '2026.5.7+24'); + expect(config.bundleIdentifier, 'org.parres.whitenoise.staging'); + expect(config.locale, 'en-US'); + expect(config.osName, 'android'); + expect(config.deviceClass, ProductAnalyticsDeviceClass.phone); + expect(config.isDebug, isFalse); + final backend = config.backend as ProductAnalyticsBackend_Aptabase; + expect(backend.config.host, 'https://analytics.example.com'); + expect(backend.config.appKey, 'staging-app-key'); + }); + + test('uses production bundle metadata with the supplied production Aptabase key', () { + final config = buildProductAnalyticsConfig( + const ProductAnalyticsConfigInput( + appVersion: '2026.5.7+24', + bundleIdentifier: 'org.parres.whitenoise', + locale: 'it', + platform: TargetPlatform.iOS, + logicalSize: Size(820, 1180), + isDebug: false, + aptabaseHost: 'https://analytics.example.com/', + aptabaseAppKey: 'production-app-key', + ), + ); + + expect(config.bundleIdentifier, 'org.parres.whitenoise'); + expect(config.locale, 'it'); + expect(config.osName, 'ios'); + expect(config.deviceClass, ProductAnalyticsDeviceClass.tablet); + final backend = config.backend as ProductAnalyticsBackend_Aptabase; + expect(backend.config.host, 'https://analytics.example.com'); + expect(backend.config.appKey, 'production-app-key'); + }); + + test('disables analytics when Aptabase config is missing', () { + final config = buildProductAnalyticsConfig( + const ProductAnalyticsConfigInput( + appVersion: '2026.5.7+24', + bundleIdentifier: 'org.parres.whitenoise', + locale: 'en', + platform: TargetPlatform.android, + logicalSize: Size(390, 844), + isDebug: true, + aptabaseHost: '', + aptabaseAppKey: '', + ), + ); + + expect(config.backend, const ProductAnalyticsBackend.disabled()); + expect(config.isDebug, isTrue); + expect(config.deviceClass, ProductAnalyticsDeviceClass.phone); + }); + + test('marks desktop platforms as desktop device class', () { + final config = buildProductAnalyticsConfig( + const ProductAnalyticsConfigInput( + appVersion: '2026.5.7+24', + bundleIdentifier: 'org.parres.whitenoise', + locale: 'en', + platform: TargetPlatform.macOS, + logicalSize: Size(1440, 900), + isDebug: false, + aptabaseHost: 'https://analytics.example.com', + aptabaseAppKey: 'desktop-app-key', + ), + ); + + expect(config.osName, 'macos'); + expect(config.deviceClass, ProductAnalyticsDeviceClass.desktop); + }); + }); +} diff --git a/test/services/product_analytics_service_test.dart b/test/services/product_analytics_service_test.dart new file mode 100644 index 000000000..db5baed70 --- /dev/null +++ b/test/services/product_analytics_service_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart'; + +void main() { + group('ProductAnalyticsService', () { + late _FakeProductAnalyticsApi api; + late ProductAnalyticsService service; + + setUp(() { + api = _FakeProductAnalyticsApi(); + service = ProductAnalyticsService( + readSettings: api.readSettings, + setEnabled: api.setEnabled, + track: api.track, + flush: api.flush, + consentVersion: api.consentVersion, + ); + }); + + test('reads disabled settings by default', () async { + final settings = await service.settings(); + + expect(settings.enabled, isFalse); + expect(settings.consentVersion, 'product-analytics-v1'); + }); + + test('onboarding opt-in enables analytics with the Rust consent version', () async { + api.nextConsentVersion = 'product-analytics-v2'; + + await service.setAnalyticsEnabled(true); + + expect(api.setEnabledCalls, [ + (enabled: true, consentVersion: 'product-analytics-v2'), + ]); + expect(api.trackedEvents, isEmpty); + }); + + test('settings opt-out disables analytics with the Rust consent version', () async { + api.nextConsentVersion = 'product-analytics-v2'; + + await service.setAnalyticsEnabled(false); + + expect(api.setEnabledCalls, [ + (enabled: false, consentVersion: 'product-analytics-v2'), + ]); + expect(api.trackedEvents, isEmpty); + }); + + test( + 'tracks only Flutter-owned typed Rust events and never talks to Aptabase directly', + () async { + await service.trackAppStarted(platform: AnalyticsPlatform.android); + await service.trackAppForegrounded(platform: AnalyticsPlatform.ios); + await service.trackAppBackgrounded(platform: AnalyticsPlatform.android); + await service.trackOnboardingStarted(); + await service.trackOnboardingCompleted(); + + expect(api.trackedEvents.map((event) => event.name), [ + ProductAnalyticsEventName.appStarted, + ProductAnalyticsEventName.appForegrounded, + ProductAnalyticsEventName.appBackgrounded, + ProductAnalyticsEventName.onboardingStarted, + ProductAnalyticsEventName.onboardingCompleted, + ]); + expect(api.trackedEvents.first.stringProps, [ + const ProductAnalyticsStringProp(key: 'platform', value: 'android'), + ]); + expect(api.trackedEvents[1].stringProps, [ + const ProductAnalyticsStringProp(key: 'platform', value: 'ios'), + ]); + expect(api.trackedEvents.last.stringProps, isEmpty); + }, + ); + + test('swallows analytics failures so product flows can continue', () async { + api.shouldThrow = true; + + final settings = await service.settings(); + await service.setAnalyticsEnabled(true); + await service.trackOnboardingCompleted(); + await service.flush(); + + expect(settings.enabled, isFalse); + expect(api.setEnabledCalls, isEmpty); + }); + }); +} + +class _FakeProductAnalyticsApi { + bool shouldThrow = false; + String nextConsentVersion = 'product-analytics-v1'; + final setEnabledCalls = <({bool enabled, String consentVersion})>[]; + final trackedEvents = []; + + Future consentVersion() async { + if (shouldThrow) throw Exception('consent version failed'); + return nextConsentVersion; + } + + Future readSettings() async { + if (shouldThrow) throw Exception('settings failed'); + return ProductAnalyticsSettings( + enabled: false, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + consentVersion: nextConsentVersion, + ); + } + + Future setEnabled({ + required bool enabled, + required String consentVersion, + }) async { + if (shouldThrow) throw Exception('set enabled failed'); + setEnabledCalls.add((enabled: enabled, consentVersion: consentVersion)); + return ProductAnalyticsSettings( + enabled: enabled, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + consentVersion: consentVersion, + ); + } + + Future track({ + required ProductAnalyticsEvent event, + }) async { + if (shouldThrow) throw Exception('track failed'); + trackedEvents.add(event); + return ProductAnalyticsTrackStatus.queued; + } + + Future flush() async { + if (shouldThrow) throw Exception('flush failed'); + return ProductAnalyticsFlushStatus.flushed; + } +} diff --git a/test/widgets/wn_slate_test.dart b/test/widgets/wn_slate_test.dart index 850d3c63b..7bde59be3 100644 --- a/test/widgets/wn_slate_test.dart +++ b/test/widgets/wn_slate_test.dart @@ -99,6 +99,53 @@ void main() { expect(find.byType(WnSlate), findsOneWidget); }); + testWidgets('renders systemNotice above header by default', (tester) async { + await mountWidget( + const WnSlate( + animateContent: false, + systemNotice: SizedBox( + key: Key('system_notice'), + height: 20, + child: Text('System Notice'), + ), + header: SizedBox( + key: Key('header'), + height: 20, + child: Text('Header'), + ), + ), + tester, + ); + + final noticeTop = tester.getTopLeft(find.byKey(const Key('system_notice'))).dy; + final headerTop = tester.getTopLeft(find.byKey(const Key('header'))).dy; + expect(noticeTop, lessThan(headerTop)); + }); + + testWidgets('renders systemNotice below header when configured', (tester) async { + await mountWidget( + const WnSlate( + animateContent: false, + systemNoticePosition: WnSlateSystemNoticePosition.belowHeader, + systemNotice: SizedBox( + key: Key('system_notice'), + height: 20, + child: Text('System Notice'), + ), + header: SizedBox( + key: Key('header'), + height: 20, + child: Text('Header'), + ), + ), + tester, + ); + + final noticeTop = tester.getTopLeft(find.byKey(const Key('system_notice'))).dy; + final headerTop = tester.getTopLeft(find.byKey(const Key('header'))).dy; + expect(noticeTop, greaterThan(headerTop)); + }); + group('without shrinkWrapContent', () { testWidgets('child uses all available space left after system notice', (tester) async { await mountWidget( diff --git a/test/widgets/wn_system_notice_test.dart b/test/widgets/wn_system_notice_test.dart index 25299df84..7a66f8209 100644 --- a/test/widgets/wn_system_notice_test.dart +++ b/test/widgets/wn_system_notice_test.dart @@ -176,6 +176,29 @@ void main() { }); group('Content and Actions', () { + testWidgets('uses custom background color when provided', (tester) async { + await mountWidget( + WnSystemNotice( + title: 'Notice', + backgroundColor: Colors.pink, + variant: WnSystemNoticeVariant.dismissible, + onDismiss: () {}, + ), + tester, + ); + + final container = tester.widget( + find + .descendant( + of: find.byType(WnSystemNotice), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, Colors.pink); + }); + testWidgets('renders description when appropriate', (tester) async { await mountWidget( WnSystemNotice( @@ -205,6 +228,33 @@ void main() { expect(find.text('Primary'), findsOneWidget); expect(find.text('Secondary'), findsOneWidget); }); + + testWidgets('renders horizontal actions side by side', (tester) async { + await mountWidget( + WnSystemNotice( + title: 'Notice', + variant: WnSystemNoticeVariant.expanded, + actionLayout: WnSystemNoticeActionLayout.horizontal, + primaryAction: const WnButton( + key: Key('primary_action'), + text: 'Primary', + onPressed: null, + ), + secondaryAction: const WnButton( + key: Key('secondary_action'), + text: 'Secondary', + onPressed: null, + ), + onToggle: () {}, + ), + tester, + ); + + final primaryTopLeft = tester.getTopLeft(find.byKey(const Key('primary_action'))); + final secondaryTopLeft = tester.getTopLeft(find.byKey(const Key('secondary_action'))); + expect(primaryTopLeft.dx, greaterThan(secondaryTopLeft.dx)); + expect(primaryTopLeft.dy, equals(secondaryTopLeft.dy)); + }); }); group('Interactions', () { @@ -380,6 +430,26 @@ void main() { expect(dismissed, isTrue); }); + + testWidgets('can call onDismiss immediately without exit animation', (tester) async { + var dismissed = false; + await mountWidget( + WnSystemNotice( + title: 'Swap me', + variant: WnSystemNoticeVariant.dismissible, + animateDismiss: false, + onDismiss: () => dismissed = true, + ), + tester, + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('systemNotice_actionIcon'))); + await tester.pump(); + + expect(dismissed, isTrue); + }); }); group('Auto-hide', () { diff --git a/test/widgets/wn_toggle_test.dart b/test/widgets/wn_toggle_test.dart new file mode 100644 index 000000000..25ae761ab --- /dev/null +++ b/test/widgets/wn_toggle_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/widgets/wn_toggle.dart'; + +import '../test_helpers.dart'; + +void main() { + group('WnToggle', () { + testWidgets('renders Figma-sized switch', (tester) async { + await mountWidget(WnToggle(value: false, onChanged: (_) {}), tester); + + final size = tester.getSize(find.byType(WnToggle)); + + expect(size.width, 56); + expect(size.height, 28); + }); + + testWidgets('calls onChanged with toggled value', (tester) async { + bool? changedValue; + await mountWidget( + WnToggle( + value: false, + onChanged: (value) => changedValue = value, + ), + tester, + ); + + await tester.tap(find.byType(WnToggle)); + + expect(changedValue, isTrue); + }); + + testWidgets('does not call onChanged when disabled', (tester) async { + bool changed = false; + await mountWidget( + WnToggle( + value: false, + enabled: false, + onChanged: (_) => changed = true, + ), + tester, + ); + + await tester.tap(find.byType(WnToggle)); + + expect(changed, isFalse); + }); + }); +} diff --git a/widgetbook/lib/components/wn_toggle.dart b/widgetbook/lib/components/wn_toggle.dart new file mode 100644 index 000000000..a143572e9 --- /dev/null +++ b/widgetbook/lib/components/wn_toggle.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/widgets/wn_toggle.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +class WnToggleStory extends StatelessWidget { + const WnToggleStory({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} + +@widgetbook.UseCase(name: 'Toggle', type: WnToggleStory) +Widget wnToggleShowcase(BuildContext context) { + final colors = context.colors; + + return Scaffold( + backgroundColor: colors.backgroundPrimary, + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Toggle', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: colors.backgroundContentPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'A compact on/off control for settings rows.', + style: TextStyle( + fontSize: 14, + color: colors.backgroundContentSecondary, + ), + ), + const SizedBox(height: 32), + Text( + 'Playground', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: colors.backgroundContentPrimary, + ), + ), + const SizedBox(height: 16), + _InteractiveToggle(context: context), + const SizedBox(height: 32), + Divider(color: colors.borderTertiary), + const SizedBox(height: 24), + Wrap( + spacing: 32, + runSpacing: 24, + children: const [ + _ToggleExample( + label: 'Off', + child: WnToggle(value: false, onChanged: _noopOnChanged), + ), + _ToggleExample( + label: 'On', + child: WnToggle(value: true, onChanged: _noopOnChanged), + ), + _ToggleExample( + label: 'Disabled', + child: WnToggle( + value: true, + enabled: false, + onChanged: _noopOnChanged, + ), + ), + ], + ), + ], + ), + ); +} + +void _noopOnChanged(bool _) {} + +class _InteractiveToggle extends HookWidget { + const _InteractiveToggle({required this.context}); + + final BuildContext context; + + @override + Widget build(BuildContext _) { + final value = useState(false); + final enabled = context.knobs.boolean(label: 'Enabled', initialValue: true); + + return WnToggle( + value: value.value, + enabled: enabled, + onChanged: (v) { + value.value = v; + }, + ); + } +} + +class _ToggleExample extends StatelessWidget { + const _ToggleExample({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: colors.backgroundContentSecondary, + ), + ), + const SizedBox(height: 8), + child, + ], + ); + } +} diff --git a/widgetbook/lib/main.directories.g.dart b/widgetbook/lib/main.directories.g.dart index 0f9021aed..fd9a2b131 100644 --- a/widgetbook/lib/main.directories.g.dart +++ b/widgetbook/lib/main.directories.g.dart @@ -72,6 +72,8 @@ import 'package:whitenoise_widgetbook/components/wn_profile_switcher_item.dart' as _whitenoise_widgetbook_components_wn_profile_switcher_item; import 'package:whitenoise_widgetbook/components/wn_slate_headers.dart' as _whitenoise_widgetbook_components_wn_slate_headers; +import 'package:whitenoise_widgetbook/components/wn_toggle.dart' + as _whitenoise_widgetbook_components_wn_toggle; import 'package:whitenoise_widgetbook/components/wn_user_item.dart' as _whitenoise_widgetbook_components_wn_user_item; import 'package:whitenoise_widgetbook/foundations/semantic_colors.dart' @@ -428,6 +430,16 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'WnToggleStory', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Toggle', + builder: + _whitenoise_widgetbook_components_wn_toggle.wnToggleShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'WnTooltipStory', useCases: [ From 0e204c4435b2c7f91743dc19b1bbe291f5003929 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Tue, 19 May 2026 17:02:34 +0200 Subject: [PATCH 2/2] Address analytics review feedback --- lib/l10n/app_de.arb | 12 ++-- lib/l10n/app_es.arb | 12 ++-- lib/l10n/app_fr.arb | 12 ++-- lib/l10n/app_it.arb | 12 ++-- lib/l10n/app_pt.arb | 12 ++-- lib/l10n/app_ru.arb | 12 ++-- lib/l10n/app_tr.arb | 12 ++-- lib/l10n/app_zh.arb | 12 ++-- lib/l10n/app_zh_Hant.arb | 12 ++-- lib/l10n/generated/app_localizations_de.dart | 12 ++-- lib/l10n/generated/app_localizations_es.dart | 12 ++-- lib/l10n/generated/app_localizations_fr.dart | 12 ++-- lib/l10n/generated/app_localizations_it.dart | 12 ++-- lib/l10n/generated/app_localizations_pt.dart | 12 ++-- lib/l10n/generated/app_localizations_ru.dart | 12 ++-- lib/l10n/generated/app_localizations_tr.dart | 12 ++-- lib/l10n/generated/app_localizations_zh.dart | 26 ++++---- lib/providers/product_analytics_provider.dart | 12 +++- lib/screens/chat_list_screen.dart | 24 ++++--- lib/services/product_analytics_config.dart | 50 ++++++++++----- lib/services/product_analytics_service.dart | 4 +- lib/widgets/wn_toggle.dart | 2 +- .../product_analytics_provider_test.dart | 64 +++++++++++++++++++ test/screens/chat_list_screen_test.dart | 2 +- .../product_analytics_service_test.dart | 10 +++ 25 files changed, 245 insertions(+), 141 deletions(-) create mode 100644 test/providers/product_analytics_provider_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 351f67e82..a4dd0f237 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Profil einrichten", "createProfile": "Profil erstellen", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Hilf mit, White Noise zu verbessern", + "analyticsConsentDescription": "Teile datenschutzfreundliche Nutzungsanalysen. Wir senden niemals Nachrichteninhalte, Kontakte, öffentliche Schlüssel, Relay-URLs, Gruppen-IDs, Nachrichten-IDs oder Profildaten. Du kannst dies jederzeit deaktivieren.", + "analyticsConsentSettingsDescription": "Teile anonyme Nutzungsdaten, damit wir Fehler finden und die App verbessern können. Nachrichten, Kontakte und Schlüssel werden nie einbezogen.", + "analyticsPromptTitle": "Möchtest du helfen, White Noise zu verbessern?", + "analyticsPromptNotNow": "Nicht jetzt", + "analyticsPromptShare": "Analysedaten teilen", "chooseName": "Name", "enterYourName": "Namen eingeben", "introduceYourself": "Über dich", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index da4327f11..9c1b49d5a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurar perfil", "createProfile": "Crear perfil", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Ayuda a mejorar White Noise", + "analyticsConsentDescription": "Comparte analíticas de uso que preservan la privacidad. Nunca enviamos contenido de mensajes, contactos, claves públicas, URLs de relés, IDs de grupos, IDs de mensajes ni datos del perfil. Puedes desactivarlo cuando quieras.", + "analyticsConsentSettingsDescription": "Comparte datos de uso anónimos para ayudarnos a encontrar errores y mejorar la app. Nunca se incluyen mensajes, contactos ni claves.", + "analyticsPromptTitle": "¿Ayudar a mejorar White Noise?", + "analyticsPromptNotNow": "Ahora no", + "analyticsPromptShare": "Compartir analíticas", "chooseName": "Nombre", "enterYourName": "Introduce tu nombre", "introduceYourself": "Acerca de", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d8e28c1c8..77765236e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurer le profil", "createProfile": "Créer le profil", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Aidez-nous à améliorer White Noise", + "analyticsConsentDescription": "Partagez des analyses d'utilisation respectueuses de la confidentialité. Nous n'envoyons jamais le contenu des messages, les contacts, les clés publiques, les URL de relais, les identifiants de groupes, les identifiants de messages ni les données de profil. Vous pouvez désactiver cette option à tout moment.", + "analyticsConsentSettingsDescription": "Partagez des données d'utilisation anonymes pour nous aider à trouver des bugs et à améliorer l'app. Les messages, contacts et clés ne sont jamais inclus.", + "analyticsPromptTitle": "Aider à améliorer White Noise ?", + "analyticsPromptNotNow": "Pas maintenant", + "analyticsPromptShare": "Partager les analyses", "chooseName": "Nom", "enterYourName": "Entrez votre nom", "introduceYourself": "À propos", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 84294cca4..ebdec0c02 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configura profilo", "createProfile": "Crea profilo", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Aiutaci a migliorare White Noise", + "analyticsConsentDescription": "Condividi analisi d'uso che tutelano la privacy. Non inviamo mai contenuti dei messaggi, contatti, chiavi pubbliche, URL dei relay, ID dei gruppi, ID dei messaggi o dati del profilo. Puoi disattivarlo in qualsiasi momento.", + "analyticsConsentSettingsDescription": "Condividi dati d'uso anonimi per aiutarci a trovare bug e migliorare l'app. Messaggi, contatti e chiavi non sono mai inclusi.", + "analyticsPromptTitle": "Aiutare a migliorare White Noise?", + "analyticsPromptNotNow": "Non ora", + "analyticsPromptShare": "Condividi analisi", "chooseName": "Nome", "enterYourName": "Inserisci il tuo nome", "introduceYourself": "Informazioni", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 0f9971a33..60153cff4 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Configurar perfil", "createProfile": "Criar perfil", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Ajude a melhorar o White Noise", + "analyticsConsentDescription": "Compartilhe análises de uso que preservam a privacidade. Nunca enviamos conteúdo de mensagens, contatos, chaves públicas, URLs de relays, IDs de grupos, IDs de mensagens ou dados de perfil. Você pode desativar isso a qualquer momento.", + "analyticsConsentSettingsDescription": "Compartilhe dados de uso anônimos para nos ajudar a encontrar bugs e melhorar o app. Mensagens, contatos e chaves nunca são incluídos.", + "analyticsPromptTitle": "Ajudar a melhorar o White Noise?", + "analyticsPromptNotNow": "Agora não", + "analyticsPromptShare": "Compartilhar análises", "chooseName": "Nome", "enterYourName": "Digite seu nome", "introduceYourself": "Sobre", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f840fdd12..58665a1d8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Настройка профиля", "createProfile": "Создать профиль", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "Помогите улучшить White Noise", + "analyticsConsentDescription": "Делитесь обезличенной аналитикой использования с сохранением конфиденциальности. Мы никогда не отправляем содержимое сообщений, контакты, публичные ключи, URL реле, идентификаторы групп, идентификаторы сообщений или данные профиля. Вы можете отключить это в любое время.", + "analyticsConsentSettingsDescription": "Делитесь анонимными данными об использовании, чтобы помочь нам находить ошибки и улучшать приложение. Сообщения, контакты и ключи никогда не включаются.", + "analyticsPromptTitle": "Помочь улучшить White Noise?", + "analyticsPromptNotNow": "Не сейчас", + "analyticsPromptShare": "Поделиться аналитикой", "chooseName": "Имя", "enterYourName": "Введите ваше имя", "introduceYourself": "О себе", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 761990491..a62644096 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "Profil oluştur", "createProfile": "Profil oluştur", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "White Noise'u iyileştirmeye yardım edin", + "analyticsConsentDescription": "Gizliliği koruyan kullanım analizlerini paylaşın. Mesaj içeriklerini, kişileri, açık anahtarları, röle URL'lerini, grup kimliklerini, mesaj kimliklerini veya profil verilerini asla göndermeyiz. Bunu istediğiniz zaman kapatabilirsiniz.", + "analyticsConsentSettingsDescription": "Hataları bulmamıza ve uygulamayı iyileştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın. Mesajlar, kişiler ve anahtarlar hiçbir zaman dahil edilmez.", + "analyticsPromptTitle": "White Noise'u iyileştirmeye yardım etmek ister misiniz?", + "analyticsPromptNotNow": "Şimdi değil", + "analyticsPromptShare": "Analizleri paylaş", "chooseName": "İsim", "enterYourName": "Adınızı girin", "introduceYourself": "Hakkında", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5fb1a7b6b..dd739f20b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "设置个人资料", "createProfile": "创建个人资料", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "帮助改进 White Noise", + "analyticsConsentDescription": "分享保护隐私的使用分析。我们绝不会发送消息内容、联系人、公钥、中继 URL、群组 ID、消息 ID 或个人资料数据。您可以随时关闭。", + "analyticsConsentSettingsDescription": "分享匿名使用数据,帮助我们发现问题并改进应用。消息、联系人和密钥绝不会包含在内。", + "analyticsPromptTitle": "帮助改进 White Noise?", + "analyticsPromptNotNow": "暂不", + "analyticsPromptShare": "分享分析数据", "chooseName": "名称", "enterYourName": "输入您的名称", "introduceYourself": "简介", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 81e47a260..143f67126 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -14,12 +14,12 @@ "nsecPlaceholder": "nsec...", "setupProfile": "設定個人檔案", "createProfile": "建立個人檔案", - "analyticsConsentTitle": "Help improve White Noise", - "analyticsConsentDescription": "Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.", - "analyticsConsentSettingsDescription": "Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.", - "analyticsPromptTitle": "Help improve White Noise?", - "analyticsPromptNotNow": "Not now", - "analyticsPromptShare": "Share analytics", + "analyticsConsentTitle": "協助改進 White Noise", + "analyticsConsentDescription": "分享保護隱私的使用分析。我們絕不會傳送訊息內容、聯絡人、公鑰、中繼站 URL、群組 ID、訊息 ID 或個人檔案資料。您可以隨時關閉。", + "analyticsConsentSettingsDescription": "分享匿名使用資料,協助我們找出問題並改進應用程式。訊息、聯絡人和金鑰絕不會包含在內。", + "analyticsPromptTitle": "協助改進 White Noise?", + "analyticsPromptNotNow": "暫時不要", + "analyticsPromptShare": "分享分析資料", "chooseName": "名稱", "enterYourName": "輸入您的名稱", "introduceYourself": "簡介", diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a1672758d..56b38729b 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -67,24 +67,24 @@ class AppLocalizationsDe extends AppLocalizations { String get createProfile => 'Profil erstellen'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Hilf mit, White Noise zu verbessern'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Teile datenschutzfreundliche Nutzungsanalysen. Wir senden niemals Nachrichteninhalte, Kontakte, öffentliche Schlüssel, Relay-URLs, Gruppen-IDs, Nachrichten-IDs oder Profildaten. Du kannst dies jederzeit deaktivieren.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Teile anonyme Nutzungsdaten, damit wir Fehler finden und die App verbessern können. Nachrichten, Kontakte und Schlüssel werden nie einbezogen.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'Möchtest du helfen, White Noise zu verbessern?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Nicht jetzt'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Analysedaten teilen'; @override String get chooseName => 'Name'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index d295b6825..ea767330e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -67,24 +67,24 @@ class AppLocalizationsEs extends AppLocalizations { String get createProfile => 'Crear perfil'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Ayuda a mejorar White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Comparte analíticas de uso que preservan la privacidad. Nunca enviamos contenido de mensajes, contactos, claves públicas, URLs de relés, IDs de grupos, IDs de mensajes ni datos del perfil. Puedes desactivarlo cuando quieras.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Comparte datos de uso anónimos para ayudarnos a encontrar errores y mejorar la app. Nunca se incluyen mensajes, contactos ni claves.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => '¿Ayudar a mejorar White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Ahora no'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Compartir analíticas'; @override String get chooseName => 'Nombre'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index c1164fcdd..12f2fd966 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -67,24 +67,24 @@ class AppLocalizationsFr extends AppLocalizations { String get createProfile => 'Créer le profil'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Aidez-nous à améliorer White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Partagez des analyses d\'utilisation respectueuses de la confidentialité. Nous n\'envoyons jamais le contenu des messages, les contacts, les clés publiques, les URL de relais, les identifiants de groupes, les identifiants de messages ni les données de profil. Vous pouvez désactiver cette option à tout moment.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Partagez des données d\'utilisation anonymes pour nous aider à trouver des bugs et à améliorer l\'app. Les messages, contacts et clés ne sont jamais inclus.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'Aider à améliorer White Noise ?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Pas maintenant'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Partager les analyses'; @override String get chooseName => 'Nom'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 68fdbc4e1..1a05be9cf 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -67,24 +67,24 @@ class AppLocalizationsIt extends AppLocalizations { String get createProfile => 'Crea profilo'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Aiutaci a migliorare White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Condividi analisi d\'uso che tutelano la privacy. Non inviamo mai contenuti dei messaggi, contatti, chiavi pubbliche, URL dei relay, ID dei gruppi, ID dei messaggi o dati del profilo. Puoi disattivarlo in qualsiasi momento.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Condividi dati d\'uso anonimi per aiutarci a trovare bug e migliorare l\'app. Messaggi, contatti e chiavi non sono mai inclusi.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'Aiutare a migliorare White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Non ora'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Condividi analisi'; @override String get chooseName => 'Nome'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index f7e86b61b..57214a221 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -67,24 +67,24 @@ class AppLocalizationsPt extends AppLocalizations { String get createProfile => 'Criar perfil'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Ajude a melhorar o White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Compartilhe análises de uso que preservam a privacidade. Nunca enviamos conteúdo de mensagens, contatos, chaves públicas, URLs de relays, IDs de grupos, IDs de mensagens ou dados de perfil. Você pode desativar isso a qualquer momento.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Compartilhe dados de uso anônimos para nos ajudar a encontrar bugs e melhorar o app. Mensagens, contatos e chaves nunca são incluídos.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'Ajudar a melhorar o White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Agora não'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Compartilhar análises'; @override String get chooseName => 'Nome'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 8cb5e35c6..778b0a149 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -67,24 +67,24 @@ class AppLocalizationsRu extends AppLocalizations { String get createProfile => 'Создать профиль'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'Помогите улучшить White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Делитесь обезличенной аналитикой использования с сохранением конфиденциальности. Мы никогда не отправляем содержимое сообщений, контакты, публичные ключи, URL реле, идентификаторы групп, идентификаторы сообщений или данные профиля. Вы можете отключить это в любое время.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Делитесь анонимными данными об использовании, чтобы помочь нам находить ошибки и улучшать приложение. Сообщения, контакты и ключи никогда не включаются.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'Помочь улучшить White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Не сейчас'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Поделиться аналитикой'; @override String get chooseName => 'Имя'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 489841ab9..eab9bf330 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -67,24 +67,24 @@ class AppLocalizationsTr extends AppLocalizations { String get createProfile => 'Profil oluştur'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => 'White Noise\'u iyileştirmeye yardım edin'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + 'Gizliliği koruyan kullanım analizlerini paylaşın. Mesaj içeriklerini, kişileri, açık anahtarları, röle URL\'lerini, grup kimliklerini, mesaj kimliklerini veya profil verilerini asla göndermeyiz. Bunu istediğiniz zaman kapatabilirsiniz.'; @override String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + 'Hataları bulmamıza ve uygulamayı iyileştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın. Mesajlar, kişiler ve anahtarlar hiçbir zaman dahil edilmez.'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => 'White Noise\'u iyileştirmeye yardım etmek ister misiniz?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => 'Şimdi değil'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => 'Analizleri paylaş'; @override String get chooseName => 'İsim'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index f635fe3fd..6ecd5899d 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -67,24 +67,23 @@ class AppLocalizationsZh extends AppLocalizations { String get createProfile => '创建个人资料'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => '帮助改进 White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + '分享保护隐私的使用分析。我们绝不会发送消息内容、联系人、公钥、中继 URL、群组 ID、消息 ID 或个人资料数据。您可以随时关闭。'; @override - String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + String get analyticsConsentSettingsDescription => '分享匿名使用数据,帮助我们发现问题并改进应用。消息、联系人和密钥绝不会包含在内。'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => '帮助改进 White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => '暂不'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => '分享分析数据'; @override String get chooseName => '名称'; @@ -1514,24 +1513,23 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get createProfile => '建立個人檔案'; @override - String get analyticsConsentTitle => 'Help improve White Noise'; + String get analyticsConsentTitle => '協助改進 White Noise'; @override String get analyticsConsentDescription => - 'Share privacy-preserving usage analytics. We never send message content, contacts, public keys, relay URLs, group ids, message ids, or profile data. You can turn this off anytime.'; + '分享保護隱私的使用分析。我們絕不會傳送訊息內容、聯絡人、公鑰、中繼站 URL、群組 ID、訊息 ID 或個人檔案資料。您可以隨時關閉。'; @override - String get analyticsConsentSettingsDescription => - 'Share anonymous usage data to help us find bugs and improve the app. Messages, contacts, and keys are never included.'; + String get analyticsConsentSettingsDescription => '分享匿名使用資料,協助我們找出問題並改進應用程式。訊息、聯絡人和金鑰絕不會包含在內。'; @override - String get analyticsPromptTitle => 'Help improve White Noise?'; + String get analyticsPromptTitle => '協助改進 White Noise?'; @override - String get analyticsPromptNotNow => 'Not now'; + String get analyticsPromptNotNow => '暫時不要'; @override - String get analyticsPromptShare => 'Share analytics'; + String get analyticsPromptShare => '分享分析資料'; @override String get chooseName => '名稱'; diff --git a/lib/providers/product_analytics_provider.dart b/lib/providers/product_analytics_provider.dart index afb10182f..ade2f3e77 100644 --- a/lib/providers/product_analytics_provider.dart +++ b/lib/providers/product_analytics_provider.dart @@ -4,14 +4,17 @@ import 'package:whitenoise/src/rust/api/product_analytics.dart' as rust_analytic class ProductAnalyticsSettingsNotifier extends AsyncNotifier { + int _setEnabledRequestId = 0; + @override Future build() { return ref.read(productAnalyticsServiceProvider).settings(); } Future setEnabled(bool enabled) async { + final requestId = ++_setEnabledRequestId; final current = state.value; - if (current != null) { + if (requestId == _setEnabledRequestId && current != null) { final now = DateTime.now().toUtc(); state = AsyncData( rust_analytics.ProductAnalyticsSettings( @@ -23,8 +26,11 @@ class ProductAnalyticsSettingsNotifier ); } - final settings = await ref.read(productAnalyticsServiceProvider).setAnalyticsEnabled(enabled); - state = AsyncData(settings); + final analyticsService = ref.read(productAnalyticsServiceProvider); + final settings = await analyticsService.setAnalyticsEnabled(enabled); + if (requestId == _setEnabledRequestId) { + state = AsyncData(settings); + } } } diff --git a/lib/screens/chat_list_screen.dart b/lib/screens/chat_list_screen.dart index f1f984c74..1725c72aa 100644 --- a/lib/screens/chat_list_screen.dart +++ b/lib/screens/chat_list_screen.dart @@ -285,11 +285,13 @@ class ChatListScreen extends HookConsumerWidget { final isSearchVisible = headerOpen.value || searchQuery.value.isNotEmpty; Future resolveAnalyticsNotice() async { - if (context.mounted) { - analyticsStepResolved.value = true; - } - await ref.read(analyticsOnboardingResolutionServiceProvider).markResolved(pubkey); - ref.invalidate(analyticsOnboardingResolvedProvider(pubkey)); + final resolutionService = ref.read(analyticsOnboardingResolutionServiceProvider); + final resolvedProvider = analyticsOnboardingResolvedProvider(pubkey); + if (!context.mounted) return; + analyticsStepResolved.value = true; + await resolutionService.markResolved(pubkey); + if (!context.mounted) return; + ref.invalidate(resolvedProvider); } void dismissWelcomeNotice() { @@ -300,18 +302,24 @@ class ChatListScreen extends HookConsumerWidget { Future shareAnalytics() async { if (analyticsNoticeLoading.value) return; + final settingsNotifier = ref.read(productAnalyticsSettingsProvider.notifier); + final analytics = ref.read(productAnalyticsServiceProvider); analyticsNoticeLoading.value = true; try { - await ref.read(productAnalyticsSettingsProvider.notifier).setEnabled(true); + await settingsNotifier.setEnabled(true); + if (!context.mounted) return; final enabled = ref.read(productAnalyticsSettingsProvider).value?.enabled ?? false; if (enabled) { - final analytics = ref.read(productAnalyticsServiceProvider); await analytics.trackOnboardingStarted(); + if (!context.mounted) return; await analytics.trackOnboardingCompleted(); + if (!context.mounted) return; } await resolveAnalyticsNotice(); } finally { - analyticsNoticeLoading.value = false; + if (context.mounted) { + analyticsNoticeLoading.value = false; + } } } diff --git a/lib/services/product_analytics_config.dart b/lib/services/product_analytics_config.dart index 437420f94..97baeebcd 100644 --- a/lib/services/product_analytics_config.dart +++ b/lib/services/product_analytics_config.dart @@ -1,10 +1,12 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kDebugMode; import 'package:flutter/widgets.dart' show Locale, Size, WidgetsBinding; +import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:whitenoise/src/rust/api/product_analytics.dart'; const _aptabaseHost = String.fromEnvironment('APTABASE_HOST'); const _aptabaseAppKey = String.fromEnvironment('APTABASE_APP_KEY'); +final _logger = Logger('ProductAnalyticsConfig'); class ProductAnalyticsConfigInput { const ProductAnalyticsConfigInput({ @@ -29,23 +31,39 @@ class ProductAnalyticsConfigInput { } Future loadProductAnalyticsConfig() async { - final packageInfo = await PackageInfo.fromPlatform(); - final dispatcher = WidgetsBinding.instance.platformDispatcher; - final view = dispatcher.views.isEmpty ? null : dispatcher.views.first; - final logicalSize = view == null ? null : view.physicalSize / view.devicePixelRatio; + try { + final packageInfo = await PackageInfo.fromPlatform(); + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final view = dispatcher.views.isEmpty ? null : dispatcher.views.first; + final logicalSize = view == null ? null : view.physicalSize / view.devicePixelRatio; - return buildProductAnalyticsConfig( - ProductAnalyticsConfigInput( - appVersion: '${packageInfo.version}+${packageInfo.buildNumber}', - bundleIdentifier: packageInfo.packageName, - locale: localeToAnalyticsTag(dispatcher.locale), - platform: defaultTargetPlatform, - logicalSize: logicalSize, - isDebug: kDebugMode, - aptabaseHost: _aptabaseHost, - aptabaseAppKey: _aptabaseAppKey, - ), - ); + return buildProductAnalyticsConfig( + ProductAnalyticsConfigInput( + appVersion: '${packageInfo.version}+${packageInfo.buildNumber}', + bundleIdentifier: packageInfo.packageName, + locale: localeToAnalyticsTag(dispatcher.locale), + platform: defaultTargetPlatform, + logicalSize: logicalSize, + isDebug: kDebugMode, + aptabaseHost: _aptabaseHost, + aptabaseAppKey: _aptabaseAppKey, + ), + ); + } catch (e, st) { + _logger.warning('Failed to load product analytics config metadata', e, st); + return buildProductAnalyticsConfig( + ProductAnalyticsConfigInput( + appVersion: 'unknown', + bundleIdentifier: 'unknown', + locale: 'unknown', + platform: defaultTargetPlatform, + logicalSize: null, + isDebug: kDebugMode, + aptabaseHost: '', + aptabaseAppKey: '', + ), + ); + } } ProductAnalyticsConfig buildProductAnalyticsConfig(ProductAnalyticsConfigInput input) { diff --git a/lib/services/product_analytics_service.dart b/lib/services/product_analytics_service.dart index ac0745ed8..ca81e5fdf 100644 --- a/lib/services/product_analytics_service.dart +++ b/lib/services/product_analytics_service.dart @@ -144,8 +144,8 @@ class ProductAnalyticsService { Future _consentVersionOrFallback() async { try { - final version = await _consentVersion(); - return version.trim().isEmpty ? _fallbackConsentVersion : version; + final version = (await _consentVersion()).trim(); + return version.isEmpty ? _fallbackConsentVersion : version; } catch (e, st) { _logger.warning('Failed to read product analytics consent version', e, st); return _fallbackConsentVersion; diff --git a/lib/widgets/wn_toggle.dart b/lib/widgets/wn_toggle.dart index a43c37ea6..fa4b39c85 100644 --- a/lib/widgets/wn_toggle.dart +++ b/lib/widgets/wn_toggle.dart @@ -45,7 +45,7 @@ class WnToggle extends StatelessWidget { child: Container( key: thumbKey, width: 20.w, - height: 20.w, + height: 20.h, decoration: BoxDecoration( color: thumbColor, shape: BoxShape.circle, diff --git a/test/providers/product_analytics_provider_test.dart b/test/providers/product_analytics_provider_test.dart new file mode 100644 index 000000000..1e64dca33 --- /dev/null +++ b/test/providers/product_analytics_provider_test.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/providers/product_analytics_provider.dart'; +import 'package:whitenoise/services/product_analytics_service.dart'; +import 'package:whitenoise/src/rust/api/product_analytics.dart'; + +void main() { + group('ProductAnalyticsSettingsNotifier', () { + test('ignores stale setEnabled responses', () async { + final api = _DeferredProductAnalyticsApi(); + final container = ProviderContainer( + overrides: [productAnalyticsServiceProvider.overrideWithValue(api.service())], + ); + addTearDown(container.dispose); + + await container.read(productAnalyticsSettingsProvider.future); + + final first = container.read(productAnalyticsSettingsProvider.notifier).setEnabled(true); + final second = container.read(productAnalyticsSettingsProvider.notifier).setEnabled(false); + await Future.delayed(Duration.zero); + + api.completeSetEnabledCall(1, enabled: false); + await second; + api.completeSetEnabledCall(0, enabled: true); + await first; + + expect(container.read(productAnalyticsSettingsProvider).value?.enabled, isFalse); + }); + }); +} + +class _DeferredProductAnalyticsApi { + final _setEnabledCalls = >[]; + + ProductAnalyticsService service() { + return ProductAnalyticsService( + readSettings: () async => _settings(enabled: false), + setEnabled: ({required enabled, required consentVersion}) { + final completer = Completer(); + _setEnabledCalls.add(completer); + return completer.future; + }, + track: ({required event}) async => ProductAnalyticsTrackStatus.queued, + flush: () async => ProductAnalyticsFlushStatus.flushed, + consentVersion: () async => 'product-analytics-v1', + ); + } + + void completeSetEnabledCall(int index, {required bool enabled}) { + _setEnabledCalls[index].complete(_settings(enabled: enabled)); + } + + ProductAnalyticsSettings _settings({required bool enabled}) { + final now = DateTime(2026); + return ProductAnalyticsSettings( + enabled: enabled, + createdAt: now, + updatedAt: now, + consentVersion: 'product-analytics-v1', + ); + } +} diff --git a/test/screens/chat_list_screen_test.dart b/test/screens/chat_list_screen_test.dart index 0ff791104..8ce201bcd 100644 --- a/test/screens/chat_list_screen_test.dart +++ b/test/screens/chat_list_screen_test.dart @@ -1004,7 +1004,7 @@ void main() { await tester.tap(find.byKey(const Key('filter_chip_archive'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('welcome_notice')), findsNothing); + expect(find.byKey(const Key('analytics_onboarding_notice')), findsNothing); }); testWidgets('neither chip visible with no chats before pull-down', (tester) async { diff --git a/test/services/product_analytics_service_test.dart b/test/services/product_analytics_service_test.dart index db5baed70..801e0fedc 100644 --- a/test/services/product_analytics_service_test.dart +++ b/test/services/product_analytics_service_test.dart @@ -47,6 +47,16 @@ void main() { expect(api.trackedEvents, isEmpty); }); + test('trims the Rust consent version before updating consent', () async { + api.nextConsentVersion = ' product-analytics-v2 '; + + await service.setAnalyticsEnabled(true); + + expect(api.setEnabledCalls, [ + (enabled: true, consentVersion: 'product-analytics-v2'), + ]); + }); + test( 'tracks only Flutter-owned typed Rust events and never talks to Aptabase directly', () async {