diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 1c6a826d..d769f5a6 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -101,38 +101,6 @@ jobs: needs: - job_001 job_004: - name: "all; PKG: packages/nextcloud_push_proxy; `dart analyze --fatal-infos .`" - runs-on: ubuntu-latest - steps: - - name: Cache Pub hosted dependencies - uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 - with: - path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud_push_proxy;commands:analyze_0" - restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud_push_proxy - os:ubuntu-latest;pub-cache-hosted;sdk:stable - os:ubuntu-latest;pub-cache-hosted - os:ubuntu-latest - - name: Setup Dart SDK - uses: dart-lang/setup-dart@a57a6c04cf7d4840e88432aad6281d1e125f0d46 - with: - sdk: stable - - id: checkout - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c - - id: packages_nextcloud_push_proxy_pub_upgrade - name: packages/nextcloud_push_proxy; dart pub upgrade - run: dart pub upgrade - if: "always() && steps.checkout.conclusion == 'success'" - working-directory: packages/nextcloud_push_proxy - - name: "packages/nextcloud_push_proxy; dart analyze --fatal-infos ." - run: dart analyze --fatal-infos . - if: "always() && steps.packages_nextcloud_push_proxy_pub_upgrade.conclusion == 'success'" - working-directory: packages/nextcloud_push_proxy - needs: - - job_001 - job_005: name: "all; PKG: packages/nextcloud_test; `dart analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -164,7 +132,7 @@ jobs: working-directory: packages/nextcloud_test needs: - job_001 - job_006: + job_005: name: "all; PKG: packages/sort_box; `dart analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -196,7 +164,7 @@ jobs: working-directory: packages/sort_box needs: - job_001 - job_007: + job_006: name: "all; PKG: packages/spec_templates; `dart analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -228,7 +196,7 @@ jobs: working-directory: packages/spec_templates needs: - job_001 - job_008: + job_007: name: "all; PKG: packages/dynamite; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -260,7 +228,7 @@ jobs: working-directory: packages/dynamite needs: - job_001 - job_009: + job_008: name: "all; PKG: packages/file_icons; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -292,7 +260,7 @@ jobs: working-directory: packages/file_icons needs: - job_001 - job_010: + job_009: name: "all; PKG: packages/neon; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -324,7 +292,7 @@ jobs: working-directory: packages/neon needs: - job_001 - job_011: + job_010: name: "all; PKG: packages/nextcloud; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -356,39 +324,7 @@ jobs: working-directory: packages/nextcloud needs: - job_001 - job_012: - name: "all; PKG: packages/nextcloud_push_proxy; `dart format --output=none --set-exit-if-changed --line-length 120 .`" - runs-on: ubuntu-latest - steps: - - name: Cache Pub hosted dependencies - uses: actions/cache@627f0f41f6904a5b1efbaed9f96d9eb58e92e920 - with: - path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud_push_proxy;commands:format" - restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:packages/nextcloud_push_proxy - os:ubuntu-latest;pub-cache-hosted;sdk:stable - os:ubuntu-latest;pub-cache-hosted - os:ubuntu-latest - - name: Setup Dart SDK - uses: dart-lang/setup-dart@a57a6c04cf7d4840e88432aad6281d1e125f0d46 - with: - sdk: stable - - id: checkout - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c - - id: packages_nextcloud_push_proxy_pub_upgrade - name: packages/nextcloud_push_proxy; dart pub upgrade - run: dart pub upgrade - if: "always() && steps.checkout.conclusion == 'success'" - working-directory: packages/nextcloud_push_proxy - - name: "packages/nextcloud_push_proxy; dart format --output=none --set-exit-if-changed --line-length 120 ." - run: "dart format --output=none --set-exit-if-changed --line-length 120 ." - if: "always() && steps.packages_nextcloud_push_proxy_pub_upgrade.conclusion == 'success'" - working-directory: packages/nextcloud_push_proxy - needs: - - job_001 - job_013: + job_011: name: "all; PKG: packages/nextcloud_test; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -420,7 +356,7 @@ jobs: working-directory: packages/nextcloud_test needs: - job_001 - job_014: + job_012: name: "all; PKG: packages/settings; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -452,7 +388,7 @@ jobs: working-directory: packages/settings needs: - job_001 - job_015: + job_013: name: "all; PKG: packages/sort_box; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -484,7 +420,7 @@ jobs: working-directory: packages/sort_box needs: - job_001 - job_016: + job_014: name: "all; PKG: packages/spec_templates; `dart format --output=none --set-exit-if-changed --line-length 120 .`" runs-on: ubuntu-latest steps: @@ -516,7 +452,7 @@ jobs: working-directory: packages/spec_templates needs: - job_001 - job_017: + job_015: name: "all; PKG: packages/file_icons; `flutter analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -548,7 +484,7 @@ jobs: working-directory: packages/file_icons needs: - job_001 - job_018: + job_016: name: "all; PKG: packages/neon; `flutter analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -580,7 +516,7 @@ jobs: working-directory: packages/neon needs: - job_001 - job_019: + job_017: name: "all; PKG: packages/settings; `flutter analyze --fatal-infos .`" runs-on: ubuntu-latest steps: @@ -612,7 +548,7 @@ jobs: working-directory: packages/settings needs: - job_001 - job_020: + job_018: name: "all; PKG: packages/nextcloud; `dart test`" runs-on: ubuntu-latest steps: @@ -644,7 +580,7 @@ jobs: working-directory: packages/nextcloud needs: - job_001 - job_021: + job_019: name: "all; PKG: packages/sort_box; `dart test`" runs-on: ubuntu-latest steps: diff --git a/helm/nextcloud-neon/.gitignore b/helm/nextcloud-neon/.gitignore deleted file mode 100644 index 7f47975f..00000000 --- a/helm/nextcloud-neon/.gitignore +++ /dev/null @@ -1 +0,0 @@ -values.yaml diff --git a/helm/nextcloud-neon/Chart.yaml b/helm/nextcloud-neon/Chart.yaml deleted file mode 100644 index 76979767..00000000 --- a/helm/nextcloud-neon/Chart.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: "v1" -name: nextcloud-neon -version: 1.0.0 -description: nextcloud-neon diff --git a/helm/nextcloud-neon/templates/common-proxies.yaml b/helm/nextcloud-neon/templates/common-proxies.yaml deleted file mode 100644 index a8584752..00000000 --- a/helm/nextcloud-neon/templates/common-proxies.yaml +++ /dev/null @@ -1,73 +0,0 @@ ---- -kind: Deployment -apiVersion: apps/v1 -metadata: - name: common-proxies -spec: - selector: - matchLabels: - app: common-proxies - template: - metadata: - labels: - app: common-proxies - spec: - containers: - - name: common-proxies - image: "{{ .Values.common_proxies.image.repository }}:{{ .Values.common_proxies.image.tag }}" - imagePullPolicy: {{ .Values.common_proxies.image.pullPolicy }} - env: -{{ toYaml .Values.common_proxies.environment | indent 12 }} - ports: - - name: http - containerPort: 5000 - readinessProbe: - httpGet: - port: http - path: /health - livenessProbe: - httpGet: - port: http - path: /health - ---- -kind: Service -apiVersion: v1 -metadata: - name: common-proxies -spec: - type: {{ .Values.common_proxies.service.type }} - ports: - - port: {{ .Values.common_proxies.service.port }} - targetPort: http - selector: - app: common-proxies - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: common-proxies -{{ if .Values.common_proxies.ingress.annotations }} - annotations: -{{ toYaml .Values.common_proxies.ingress.annotations | indent 4 }} -{{ end }} -spec: - {{ if .Values.common_proxies.ingress.className }} - ingressClassName: {{ .Values.common_proxies.ingress.className }} - {{ end }} - rules: - - host: {{ .Values.common_proxies.ingress.host }} - http: - paths: - - path: {{ .Values.common_proxies.ingress.path }} - pathType: {{ .Values.common_proxies.ingress.pathType }} - backend: - service: - name: common-proxies - port: - number: {{ .Values.common_proxies.service.port }} -{{ if .Values.common_proxies.ingress.tls }} - tls: -{{ toYaml .Values.common_proxies.ingress.tls | indent 4 }} -{{ end }} diff --git a/helm/nextcloud-neon/templates/push-proxy.yaml b/helm/nextcloud-neon/templates/push-proxy.yaml deleted file mode 100644 index 8c48aa9a..00000000 --- a/helm/nextcloud-neon/templates/push-proxy.yaml +++ /dev/null @@ -1,91 +0,0 @@ ---- -kind: Deployment -apiVersion: apps/v1 -metadata: - name: push-proxy -spec: - selector: - matchLabels: - app: push-proxy - template: - metadata: - labels: - app: push-proxy - spec: - containers: - - name: push-proxy - image: "{{ .Values.imageNextcloudPushProxy }}" - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 8080 - readinessProbe: - httpGet: - port: http - path: /health - livenessProbe: - httpGet: - port: http - path: /health - volumeMounts: - - mountPath: /data - name: push-proxy - volumes: - - name: push-proxy - persistentVolumeClaim: - claimName: push-proxy - ---- -kind: Service -apiVersion: v1 -metadata: - name: push-proxy -spec: - type: {{ .Values.push_proxy.service.type }} - ports: - - port: {{ .Values.push_proxy.service.port }} - targetPort: http - selector: - app: push-proxy - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: push-proxy -{{ if .Values.push_proxy.ingress.annotations }} - annotations: -{{ toYaml .Values.push_proxy.ingress.annotations | indent 4 }} -{{ end }} -spec: - {{ if .Values.push_proxy.ingress.className }} - ingressClassName: {{ .Values.push_proxy.ingress.className }} - {{ end }} - rules: - - host: {{ .Values.push_proxy.ingress.host }} - http: - paths: - - path: {{ .Values.push_proxy.ingress.path }} - pathType: {{ .Values.push_proxy.ingress.pathType }} - backend: - service: - name: push-proxy - port: - number: {{ .Values.push_proxy.service.port }} -{{ if .Values.push_proxy.ingress.tls }} - tls: -{{ toYaml .Values.push_proxy.ingress.tls | indent 4 }} -{{ end }} - - ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: push-proxy -spec: - resources: - requests: - storage: 1Gi - accessModes: - - ReadWriteOnce diff --git a/packages/neon/integration_test/screenshot_test.dart b/packages/neon/integration_test/screenshot_test.dart index 4090701c..f07ce56f 100644 --- a/packages/neon/integration_test/screenshot_test.dart +++ b/packages/neon/integration_test/screenshot_test.dart @@ -126,9 +126,12 @@ Future pumpAppPage( accountsBloc, sharedPreferences, globalOptions, - null, platform, ); + final firstLaunchBloc = FirstLaunchBloc( + sharedPreferences, + disabled: true, + ); // ignore: close_sinks final userThemeStream = BehaviorSubject(); @@ -157,6 +160,9 @@ Future pumpAppPage( Provider( create: (final _) => pushNotificationsBloc, ), + Provider( + create: (final _) => firstLaunchBloc, + ), Provider>( create: (final _) => allAppImplementations, ), diff --git a/packages/neon/lib/l10n/en.arb b/packages/neon/lib/l10n/en.arb index 1bb7cc90..e0fcb88f 100644 --- a/packages/neon/lib/l10n/en.arb +++ b/packages/neon/lib/l10n/en.arb @@ -65,6 +65,7 @@ } } }, + "settingsGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "optionsCategoryGeneral": "General", "optionsCategoryTheme": "Theme", "optionsCategoryPushNotifications": "Push notifications", @@ -81,16 +82,16 @@ "globalOptionsThemeModeAutomatic": "Automatic", "globalOptionsThemeOLEDAsDark": "OLED theme as dark theme", "globalOptionsThemeKeepOriginalAccentColor": "Keep the original accent color", - "globalOptionsPushNotificationsNotice": "External services are used for delivering push notifications. While the content is encrypted and can only be read by this app, extracting metadata like the time and count of notifications is still possible.", "globalOptionsPushNotificationsEnabled": "Enabled", - "globalOptionsPushNotificationsEnabledDisabledNotice": "No UnifiedPush distributor could be found. Please go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications", + "globalOptionsPushNotificationsEnabledDisabledNotice": "No UnifiedPush distributor could be found or you denied the permission for showing notifications. Please go to the app settings and allow notifications and go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications", "globalOptionsPushNotificationsDistributor": "UnifiedPush Distributor", - "globalOptionsPushNotificationsDistributorGotifyUP": "Gotify-UP", - "globalOptionsPushNotificationsDistributorFirebaseEmbedded": "Firebase (FOSS)", - "globalOptionsPushNotificationsDistributorNtfy": "ntfy", - "globalOptionsPushNotificationsDistributorFCMUP": "FCM-UP", - "globalOptionsPushNotificationsDistributorNextPush": "NextPush", - "globalOptionsPushNotificationsDistributorNoProvider2Push": "NoProvider2Push", + "globalOptionsPushNotificationsDistributorGotifyUP": "Gotify-UP (FOSS)", + "globalOptionsPushNotificationsDistributorFirebaseEmbedded": "Firebase (proprietary)", + "globalOptionsPushNotificationsDistributorNtfy": "ntfy (FOSS)", + "globalOptionsPushNotificationsDistributorFCMUP": "FCM-UP (proprietary)", + "globalOptionsPushNotificationsDistributorNextPush": "NextPush (FOSS)", + "globalOptionsPushNotificationsDistributorNoProvider2Push": "NoProvider2Push (FOSS)", + "globalOptionsPushNotificationsDistributorConversations": "Conversations", "globalOptionsStartupMinimized": "Start minimized", "globalOptionsStartupMinimizeInsteadOfExit": "Minimize instead of exit", "globalOptionsSystemTrayEnabled": "Enable system tray", diff --git a/packages/neon/lib/l10n/localizations.dart b/packages/neon/lib/l10n/localizations.dart index d57084e0..f7febc86 100644 --- a/packages/neon/lib/l10n/localizations.dart +++ b/packages/neon/lib/l10n/localizations.dart @@ -311,6 +311,12 @@ abstract class AppLocalizations { /// **'Do you want to reset all settings for {name}?'** String settingsResetForConfirmation(String name); + /// No description provided for @settingsGoToSettingsToEnablePushNotifications. + /// + /// In en, this message translates to: + /// **'Go to the settings to enable push notifications'** + String get settingsGoToSettingsToEnablePushNotifications; + /// No description provided for @optionsCategoryGeneral. /// /// In en, this message translates to: @@ -407,12 +413,6 @@ abstract class AppLocalizations { /// **'Keep the original accent color'** String get globalOptionsThemeKeepOriginalAccentColor; - /// No description provided for @globalOptionsPushNotificationsNotice. - /// - /// In en, this message translates to: - /// **'External services are used for delivering push notifications. While the content is encrypted and can only be read by this app, extracting metadata like the time and count of notifications is still possible.'** - String get globalOptionsPushNotificationsNotice; - /// No description provided for @globalOptionsPushNotificationsEnabled. /// /// In en, this message translates to: @@ -422,7 +422,7 @@ abstract class AppLocalizations { /// No description provided for @globalOptionsPushNotificationsEnabledDisabledNotice. /// /// In en, this message translates to: - /// **'No UnifiedPush distributor could be found. Please go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications'** + /// **'No UnifiedPush distributor could be found or you denied the permission for showing notifications. Please go to the app settings and allow notifications and go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications'** String get globalOptionsPushNotificationsEnabledDisabledNotice; /// No description provided for @globalOptionsPushNotificationsDistributor. @@ -434,39 +434,45 @@ abstract class AppLocalizations { /// No description provided for @globalOptionsPushNotificationsDistributorGotifyUP. /// /// In en, this message translates to: - /// **'Gotify-UP'** + /// **'Gotify-UP (FOSS)'** String get globalOptionsPushNotificationsDistributorGotifyUP; /// No description provided for @globalOptionsPushNotificationsDistributorFirebaseEmbedded. /// /// In en, this message translates to: - /// **'Firebase (FOSS)'** + /// **'Firebase (proprietary)'** String get globalOptionsPushNotificationsDistributorFirebaseEmbedded; /// No description provided for @globalOptionsPushNotificationsDistributorNtfy. /// /// In en, this message translates to: - /// **'ntfy'** + /// **'ntfy (FOSS)'** String get globalOptionsPushNotificationsDistributorNtfy; /// No description provided for @globalOptionsPushNotificationsDistributorFCMUP. /// /// In en, this message translates to: - /// **'FCM-UP'** + /// **'FCM-UP (proprietary)'** String get globalOptionsPushNotificationsDistributorFCMUP; /// No description provided for @globalOptionsPushNotificationsDistributorNextPush. /// /// In en, this message translates to: - /// **'NextPush'** + /// **'NextPush (FOSS)'** String get globalOptionsPushNotificationsDistributorNextPush; /// No description provided for @globalOptionsPushNotificationsDistributorNoProvider2Push. /// /// In en, this message translates to: - /// **'NoProvider2Push'** + /// **'NoProvider2Push (FOSS)'** String get globalOptionsPushNotificationsDistributorNoProvider2Push; + /// No description provided for @globalOptionsPushNotificationsDistributorConversations. + /// + /// In en, this message translates to: + /// **'Conversations'** + String get globalOptionsPushNotificationsDistributorConversations; + /// No description provided for @globalOptionsStartupMinimized. /// /// In en, this message translates to: diff --git a/packages/neon/lib/l10n/localizations_en.dart b/packages/neon/lib/l10n/localizations_en.dart index 76d8f9e4..4f0bb430 100644 --- a/packages/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/lib/l10n/localizations_en.dart @@ -126,6 +126,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Do you want to reset all settings for $name?'; } + @override + String get settingsGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; + @override String get optionsCategoryGeneral => 'General'; @@ -174,37 +177,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get globalOptionsThemeKeepOriginalAccentColor => 'Keep the original accent color'; - @override - String get globalOptionsPushNotificationsNotice => - 'External services are used for delivering push notifications. While the content is encrypted and can only be read by this app, extracting metadata like the time and count of notifications is still possible.'; - @override String get globalOptionsPushNotificationsEnabled => 'Enabled'; @override String get globalOptionsPushNotificationsEnabledDisabledNotice => - 'No UnifiedPush distributor could be found. Please go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications'; + 'No UnifiedPush distributor could be found or you denied the permission for showing notifications. Please go to the app settings and allow notifications and go to https://unifiedpush.org/users/distributors and setup any of the listed distributors. Then re-open this app and you should be able to enable notifications'; @override String get globalOptionsPushNotificationsDistributor => 'UnifiedPush Distributor'; @override - String get globalOptionsPushNotificationsDistributorGotifyUP => 'Gotify-UP'; + String get globalOptionsPushNotificationsDistributorGotifyUP => 'Gotify-UP (FOSS)'; + + @override + String get globalOptionsPushNotificationsDistributorFirebaseEmbedded => 'Firebase (proprietary)'; @override - String get globalOptionsPushNotificationsDistributorFirebaseEmbedded => 'Firebase (FOSS)'; + String get globalOptionsPushNotificationsDistributorNtfy => 'ntfy (FOSS)'; @override - String get globalOptionsPushNotificationsDistributorNtfy => 'ntfy'; + String get globalOptionsPushNotificationsDistributorFCMUP => 'FCM-UP (proprietary)'; @override - String get globalOptionsPushNotificationsDistributorFCMUP => 'FCM-UP'; + String get globalOptionsPushNotificationsDistributorNextPush => 'NextPush (FOSS)'; @override - String get globalOptionsPushNotificationsDistributorNextPush => 'NextPush'; + String get globalOptionsPushNotificationsDistributorNoProvider2Push => 'NoProvider2Push (FOSS)'; @override - String get globalOptionsPushNotificationsDistributorNoProvider2Push => 'NoProvider2Push'; + String get globalOptionsPushNotificationsDistributorConversations => 'Conversations'; @override String get globalOptionsStartupMinimized => 'Start minimized'; diff --git a/packages/neon/lib/main.dart b/packages/neon/lib/main.dart index 0ad030ad..a01d0d7a 100644 --- a/packages/neon/lib/main.dart +++ b/packages/neon/lib/main.dart @@ -52,9 +52,9 @@ Future main() async { accountsBloc, sharedPreferences, globalOptions, - env, platform, ); + final firstLaunchBloc = FirstLaunchBloc(sharedPreferences); runApp( MultiProvider( @@ -80,6 +80,9 @@ Future main() async { Provider( create: (final _) => pushNotificationsBloc, ), + Provider( + create: (final _) => firstLaunchBloc, + ), Provider>( create: (final _) => allAppImplementations, ), diff --git a/packages/neon/lib/src/app.dart b/packages/neon/lib/src/app.dart index 751e596c..cf5b2dbd 100644 --- a/packages/neon/lib/src/app.dart +++ b/packages/neon/lib/src/app.dart @@ -70,13 +70,15 @@ class _NeonAppState extends State with WidgetsBindingObserver, tray.Tra const settings = RouteSettings( name: 'home', ); - Widget builder(final context) => HomePage( - account: activeAccount, - onThemeChanged: (final nextcloudTheme) { - setState(() { - _nextcloudTheme = nextcloudTheme; - }); - }, + Widget builder(final context) => Scaffold( + body: HomePage( + account: activeAccount, + onThemeChanged: (final nextcloudTheme) { + setState(() { + _nextcloudTheme = nextcloudTheme; + }); + }, + ), ); await _navigatorKey.currentState!.pushAndRemoveUntil( widget.globalOptions.navigationMode.value == NavigationMode.drawer @@ -172,7 +174,7 @@ class _NeonAppState extends State with WidgetsBindingObserver, tray.Tra final allAppImplementations = Provider.of>(context, listen: false); final matchingAppImplementations = - allAppImplementations.where((final a) => a.id == pushNotificationWithAccountID.notification.subject.app); + allAppImplementations.where((final a) => a.id == pushNotificationWithAccountID.subject.app); late AppImplementation appImplementation; if (matchingAppImplementations.isNotEmpty) { @@ -191,7 +193,7 @@ class _NeonAppState extends State with WidgetsBindingObserver, tray.Tra _accountsBloc .getAppsBloc(account) .getAppBloc(appImplementation) - .deleteNotification(pushNotificationWithAccountID.notification.subject.nid!); + .deleteNotification(pushNotificationWithAccountID.subject.nid!); } await _openAppFromExternal(account, appImplementation.id); }; @@ -199,7 +201,7 @@ class _NeonAppState extends State with WidgetsBindingObserver, tray.Tra final details = await localNotificationsPlugin.getNotificationAppLaunchDetails(); if (details != null && details.didNotificationLaunchApp && details.notificationResponse?.payload != null) { await Global.onPushNotificationClicked!( - PushNotificationWithAccountID.fromJson( + PushNotification.fromJson( json.decode(details.notificationResponse!.payload!) as Map, ), ); diff --git a/packages/neon/lib/src/blocs/apps.dart b/packages/neon/lib/src/blocs/apps.dart index c793172a..da6b957e 100644 --- a/packages/neon/lib/src/blocs/apps.dart +++ b/packages/neon/lib/src/blocs/apps.dart @@ -14,6 +14,8 @@ abstract class AppsBlocStates { BehaviorSubject> get notificationsAppImplementation; BehaviorSubject get activeAppID; + + BehaviorSubject get openNotifications; } class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates { @@ -88,6 +90,7 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates unawaited(appImplementations.close()); unawaited(notificationsAppImplementation.close()); unawaited(activeAppID.close()); + unawaited(openNotifications.close()); for (final key in _blocs.keys) { _blocs[key]!.dispose(); } @@ -107,6 +110,9 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates BehaviorSubject> notificationsAppImplementation = BehaviorSubject>(); + @override + BehaviorSubject openNotifications = BehaviorSubject(); + @override Future refresh() async { await _requestManager.wrapNextcloud, NextcloudCoreNavigationApps>( @@ -128,7 +134,7 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates activeAppID.add(appID); } } else if (appID == 'notifications') { - // TODO: Open notifications page + openNotifications.add(null); } else { throw Exception('App $appID not found'); } diff --git a/packages/neon/lib/src/blocs/first_launch.dart b/packages/neon/lib/src/blocs/first_launch.dart new file mode 100644 index 00000000..e2755d2a --- /dev/null +++ b/packages/neon/lib/src/blocs/first_launch.dart @@ -0,0 +1,30 @@ +part of '../neon.dart'; + +abstract class FirstLaunchBlocEvents {} + +abstract class FirstLaunchBlocStates { + BehaviorSubject get onFirstLaunch; +} + +class FirstLaunchBloc extends Bloc implements FirstLaunchBlocEvents, FirstLaunchBlocStates { + FirstLaunchBloc( + this._sharedPreferences, { + final bool disabled = false, + }) { + if (!disabled && !_sharedPreferences.containsKey(_keyFirstLaunch)) { + onFirstLaunch.add(null); + unawaited(_sharedPreferences.setBool(_keyFirstLaunch, false)); + } + } + + final SharedPreferences _sharedPreferences; + final _keyFirstLaunch = 'first-launch'; + + @override + void dispose() { + unawaited(onFirstLaunch.close()); + } + + @override + BehaviorSubject onFirstLaunch = BehaviorSubject(); +} diff --git a/packages/neon/lib/src/blocs/push_notifications.dart b/packages/neon/lib/src/blocs/push_notifications.dart index 4934c4bd..077a4058 100644 --- a/packages/neon/lib/src/blocs/push_notifications.dart +++ b/packages/neon/lib/src/blocs/push_notifications.dart @@ -3,7 +3,7 @@ part of '../neon.dart'; abstract class PushNotificationsBlocEvents {} abstract class PushNotificationsBlocStates { - Stream get notifications; + Stream get notifications; } class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, PushNotificationsBlocStates { @@ -11,7 +11,6 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, this._accountsBloc, this._sharedPreferences, this._globalOptions, - this._env, this._platform, ) { if (_platform.canUsePushNotifications) { @@ -35,11 +34,10 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, final SharedPreferences _sharedPreferences; late final _storage = AppStorage('notifications', _sharedPreferences); final GlobalOptions _globalOptions; - final Env? _env; late RSAKeypair _keypair; bool? _pushNotificationsEnabled; - final _notificationsController = StreamController(); + final _notificationsController = StreamController(); @override void dispose() { @@ -47,20 +45,14 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, } @override - late Stream notifications = _notificationsController.stream.asBroadcastStream(); + late Stream notifications = _notificationsController.stream.asBroadcastStream(); String _keyLastEndpoint(final Account account) => 'last-endpoint-${account.id}'; Future _setupUnifiedPush() async { await UnifiedPush.initialize( onNewEndpoint: (final endpoint, final instance) async { - Account? account; - for (final a in _accountsBloc.accounts.value) { - if (a.id == instance) { - account = a; - break; - } - } + final account = _accountsBloc.accounts.value.find(instance); if (account == null) { debugPrint('Account for $instance not found, can not process endpoint'); return; @@ -73,28 +65,17 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, debugPrint('Registering account $instance for push notifications on $endpoint'); - var proxyServerForNextcloud = 'https://nc.proxy.neon.provokateurin.de/'; - var proxyServerForClient = proxyServerForNextcloud; - if (_env != null) { - proxyServerForNextcloud = 'http://host.docker.internal:8080/'; - proxyServerForClient = 'http://${_env!.testHost}:8080/'; - } - final subscription = await account.client.notifications.registerDevice( - pushTokenHash: account.client.notifications.generatePushTokenHash(endpoint), + pushTokenHash: generatePushTokenHash(endpoint), devicePublicKey: _keypair.publicKey.toFormattedPEM(), - proxyServer: proxyServerForNextcloud, - ); - - await account.client.notifications.registerDeviceAtPushProxy( - endpoint, - subscription.ocs.data, - proxyServerForClient, + proxyServer: '$endpoint#', // This is a hack to make the Nextcloud server directly push to the endpoint ); await _storage.setString(_keyLastEndpoint(account), endpoint); - debugPrint('Account $instance registered for push notifications'); + debugPrint( + 'Account $instance registered for push notifications ${json.encode(subscription.ocs.data.toJson())}', + ); }, onMessage: PushUtils.onMessage, ); @@ -120,7 +101,13 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, Future _unregisterUnifiedPushInstances(final List accounts) async { for (final account in accounts) { - await UnifiedPush.unregister(account.client.id); + try { + await account.client.notifications.removeDevice(); + await UnifiedPush.unregister(account.client.id); + await _storage.remove(_keyLastEndpoint(account)); + } catch (e) { + debugPrint('Failed to unregister device: $e'); + } } } @@ -131,17 +118,3 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents, } } } - -class NextcloudPushNotification { - NextcloudPushNotification({ - required this.instance, - required this.priority, - required this.type, - required this.subject, - }); - - final String instance; - final String priority; - final String type; - final NextcloudNotificationsPushNotificationDecryptedSubject subject; -} diff --git a/packages/neon/lib/src/models/push_notification.dart b/packages/neon/lib/src/models/push_notification.dart new file mode 100644 index 00000000..5a9e4142 --- /dev/null +++ b/packages/neon/lib/src/models/push_notification.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:nextcloud/nextcloud.dart'; + +part 'push_notification.g.dart'; + +@JsonSerializable() +class PushNotification { + PushNotification({ + required this.accountID, + required this.priority, + required this.type, + required this.subject, + }); + + factory PushNotification.fromJson(final Map json) => _$PushNotificationFromJson(json); + Map toJson() => _$PushNotificationToJson(this); + + final String accountID; + + final String priority; + + final String type; + + final NextcloudNotificationsNotificationDecryptedSubject subject; +} diff --git a/packages/neon/lib/src/models/push_notification.g.dart b/packages/neon/lib/src/models/push_notification.g.dart new file mode 100644 index 00000000..dc2208f6 --- /dev/null +++ b/packages/neon/lib/src/models/push_notification.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_notification.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PushNotification _$PushNotificationFromJson(Map json) => PushNotification( + accountID: json['accountID'] as String, + priority: json['priority'] as String, + type: json['type'] as String, + subject: NextcloudNotificationsNotificationDecryptedSubject.fromJson(json['subject'] as Map), + ); + +Map _$PushNotificationToJson(PushNotification instance) => { + 'accountID': instance.accountID, + 'priority': instance.priority, + 'type': instance.type, + 'subject': instance.subject, + }; diff --git a/packages/neon/lib/src/models/push_notification_with_account.dart b/packages/neon/lib/src/models/push_notification_with_account.dart deleted file mode 100644 index e90b7342..00000000 --- a/packages/neon/lib/src/models/push_notification_with_account.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:nextcloud/nextcloud.dart'; - -part 'push_notification_with_account.g.dart'; - -@JsonSerializable() -class PushNotificationWithAccountID { - PushNotificationWithAccountID({ - required this.notification, - required this.accountID, - }); - - factory PushNotificationWithAccountID.fromJson(final Map json) => - _$PushNotificationWithAccountIDFromJson(json); - Map toJson() => _$PushNotificationWithAccountIDToJson(this); - - final NextcloudNotificationsPushNotification notification; - - final String accountID; -} diff --git a/packages/neon/lib/src/models/push_notification_with_account.g.dart b/packages/neon/lib/src/models/push_notification_with_account.g.dart deleted file mode 100644 index 569a6cea..00000000 --- a/packages/neon/lib/src/models/push_notification_with_account.g.dart +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'push_notification_with_account.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PushNotificationWithAccountID _$PushNotificationWithAccountIDFromJson(Map json) => - PushNotificationWithAccountID( - notification: NextcloudNotificationsPushNotification.fromJson(json['notification'] as Map), - accountID: json['accountID'] as String, - ); - -Map _$PushNotificationWithAccountIDToJson(PushNotificationWithAccountID instance) => { - 'notification': instance.notification, - 'accountID': instance.accountID, - }; diff --git a/packages/neon/lib/src/neon.dart b/packages/neon/lib/src/neon.dart index b055d96c..84b72364 100644 --- a/packages/neon/lib/src/neon.dart +++ b/packages/neon/lib/src/neon.dart @@ -25,7 +25,7 @@ import 'package:neon/src/apps/news/app.dart'; import 'package:neon/src/apps/notes/app.dart'; import 'package:neon/src/apps/notifications/app.dart'; import 'package:neon/src/models/account.dart'; -import 'package:neon/src/models/push_notification_with_account.dart'; +import 'package:neon/src/models/push_notification.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -50,6 +50,7 @@ part 'app.dart'; part 'blocs/accounts.dart'; part 'blocs/apps.dart'; part 'blocs/capabilities.dart'; +part 'blocs/first_launch.dart'; part 'blocs/login.dart'; part 'blocs/push_notifications.dart'; part 'blocs/user_details.dart'; diff --git a/packages/neon/lib/src/pages/home.dart b/packages/neon/lib/src/pages/home.dart index 35374ef3..bc0e14f9 100644 --- a/packages/neon/lib/src/pages/home.dart +++ b/packages/neon/lib/src/pages/home.dart @@ -21,16 +21,31 @@ class _HomePageState extends State { final _scaffoldKey = GlobalKey(); late GlobalOptions _globalOptions; - late CapabilitiesBloc _capabilitiesBloc; + late AccountsBloc _accountsBloc; late AppsBloc _appsBloc; + late CapabilitiesBloc _capabilitiesBloc; + late FirstLaunchBloc _firstLaunchBloc; @override void initState() { super.initState(); _globalOptions = Provider.of(context, listen: false); - _appsBloc = Provider.of(context, listen: false).getAppsBloc(widget.account); - _capabilitiesBloc = Provider.of(context, listen: false).getCapabilitiesBloc(widget.account); + _accountsBloc = Provider.of(context, listen: false); + _appsBloc = _accountsBloc.getAppsBloc(widget.account); + _capabilitiesBloc = _accountsBloc.getCapabilitiesBloc(widget.account); + _firstLaunchBloc = Provider.of(context, listen: false); + + _appsBloc.openNotifications.listen((final _) async { + final notificationsAppImplementation = _appsBloc.notificationsAppImplementation.valueOrNull; + if (notificationsAppImplementation != null) { + await _openNotifications( + notificationsAppImplementation.data!, + _accountsBloc.accounts.value, + _accountsBloc.activeAccount.value!, + ); + } + }); _capabilitiesBloc.capabilities.listen((final result) async { if (result.data != null) { @@ -83,6 +98,24 @@ class _HomePageState extends State { } }); + _firstLaunchBloc.onFirstLaunch.listen((final _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).settingsGoToSettingsToEnablePushNotifications), + action: SnackBarAction( + label: AppLocalizations.of(context).settings, + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => const SettingsPage(), + ), + ); + }, + ), + ), + ); + }); + unawaited(_checkMaintenanceMode()); } @@ -133,6 +166,34 @@ class _HomePageState extends State { ); } + Future _openNotifications( + final NotificationsApp app, + final List accounts, + final Account account, + ) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(app.name(context)), + if (accounts.length > 1) ...[ + Text( + account.client.humanReadableID, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + body: app.buildPage(context, _appsBloc), + ), + ), + ); + } + @override void dispose() { _capabilitiesBloc.dispose(); @@ -140,450 +201,432 @@ class _HomePageState extends State { } @override - Widget build(final BuildContext context) { - final accountsBloc = Provider.of(context, listen: false); - return ResultBuilder( - stream: _capabilitiesBloc.capabilities, - builder: (final context, final capabilities) => ResultBuilder>( - stream: _appsBloc.appImplementations, - builder: (final context, final appImplementations) => ResultBuilder( - stream: _appsBloc.notificationsAppImplementation, - builder: (final context, final notificationsAppImplementation) => StreamBuilder( - stream: _appsBloc.activeAppID, - builder: ( - final context, - final activeAppIDSnapshot, - ) => - StreamBuilder>( - stream: accountsBloc.accounts, + Widget build(final BuildContext context) => ResultBuilder( + stream: _capabilitiesBloc.capabilities, + builder: (final context, final capabilities) => ResultBuilder>( + stream: _appsBloc.appImplementations, + builder: (final context, final appImplementations) => ResultBuilder( + stream: _appsBloc.notificationsAppImplementation, + builder: (final context, final notificationsAppImplementation) => StreamBuilder( + stream: _appsBloc.activeAppID, builder: ( final context, - final accountsSnapshot, + final activeAppIDSnapshot, ) => - OptionBuilder( - option: _globalOptions.navigationMode, - builder: (final context, final navigationMode) => WillPopScope( - onWillPop: () async { - if (_scaffoldKey.currentState!.isDrawerOpen) { - Navigator.pop(context); - return true; - } + StreamBuilder>( + stream: _accountsBloc.accounts, + builder: ( + final context, + final accountsSnapshot, + ) => + OptionBuilder( + option: _globalOptions.navigationMode, + builder: (final context, final navigationMode) => WillPopScope( + onWillPop: () async { + if (_scaffoldKey.currentState!.isDrawerOpen) { + Navigator.pop(context); + return true; + } - _scaffoldKey.currentState!.openDrawer(); - return false; - }, - child: Builder( - builder: (final context) { - if (accountsSnapshot.hasData) { - final accounts = accountsSnapshot.data!; - final account = accounts.singleWhere((final account) => account.id == widget.account.id); + _scaffoldKey.currentState!.openDrawer(); + return false; + }, + child: Builder( + builder: (final context) { + if (accountsSnapshot.hasData) { + final accounts = accountsSnapshot.data!; + final account = accounts.singleWhere((final account) => account.id == widget.account.id); - final isQuickBar = navigationMode == NavigationMode.quickBar; - final drawer = Drawer( - width: isQuickBar ? kQuickBarWidth : null, - child: Container( - padding: isQuickBar ? const EdgeInsets.all(5) : null, - child: Column( - children: [ - Expanded( - child: Scrollbar( - child: ListView( - // Needed for the drawer header to also render in the statusbar - padding: EdgeInsets.zero, - children: [ - Builder( - builder: (final context) { - if (accountsSnapshot.hasData) { - if (isQuickBar) { - return Column( - children: [ - if (accounts.length != 1) ...[ - for (final account in accounts) ...[ - Container( - margin: const EdgeInsets.symmetric( - vertical: 5, - ), - child: Tooltip( - message: account.client.humanReadableID, - child: IconButton( - onPressed: () { - accountsBloc.setActiveAccount(account); - }, - icon: IntrinsicHeight( - child: AccountAvatar( - account: account, + final isQuickBar = navigationMode == NavigationMode.quickBar; + final drawer = Drawer( + width: isQuickBar ? kQuickBarWidth : null, + child: Container( + padding: isQuickBar ? const EdgeInsets.all(5) : null, + child: Column( + children: [ + Expanded( + child: Scrollbar( + child: ListView( + // Needed for the drawer header to also render in the statusbar + padding: EdgeInsets.zero, + children: [ + Builder( + builder: (final context) { + if (accountsSnapshot.hasData) { + if (isQuickBar) { + return Column( + children: [ + if (accounts.length != 1) ...[ + for (final account in accounts) ...[ + Container( + margin: const EdgeInsets.symmetric( + vertical: 5, + ), + child: Tooltip( + message: account.client.humanReadableID, + child: IconButton( + onPressed: () { + _accountsBloc.setActiveAccount(account); + }, + icon: IntrinsicHeight( + child: AccountAvatar( + account: account, + ), ), ), ), ), - ), - ], - Container( - margin: const EdgeInsets.only( - top: 10, - ), - child: Divider( - height: 5, - color: Theme.of(context).appBarTheme.foregroundColor, - ), - ), - ], - ], - ); - } - return DrawerHeader( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (capabilities.data != null) ...[ - if (capabilities.data!.capabilities.theming?.name != null) ...[ - Text( - capabilities.data!.capabilities.theming!.name!, - style: DefaultTextStyle.of(context).style.copyWith( - color: Theme.of(context).appBarTheme.foregroundColor, - ), - ), - ], - if (capabilities.data!.capabilities.theming?.logo != null) ...[ - Flexible( - child: CachedURLImage( - url: capabilities.data!.capabilities.theming!.logo!, + ], + Container( + margin: const EdgeInsets.only( + top: 10, + ), + child: Divider( + height: 5, + color: Theme.of(context).appBarTheme.foregroundColor, ), ), ], - ] else ...[ - ExceptionWidget( - capabilities.error, - onRetry: _capabilitiesBloc.refresh, - ), - CustomLinearProgressIndicator( - visible: capabilities.loading, - ), - ], - if (accounts.length != 1) ...[ - DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - dropdownColor: Theme.of(context).colorScheme.primary, - iconEnabledColor: Theme.of(context).colorScheme.onBackground, - value: widget.account.id, - items: accounts - .map>( - (final account) => DropdownMenuItem( - value: account.id, - child: AccountTile( - account: account, - dense: true, - textColor: - Theme.of(context).appBarTheme.foregroundColor, - ), - ), - ) - .toList(), - onChanged: (final id) { - for (final account in accounts) { - if (account.id == id) { - accountsBloc.setActiveAccount(account); - break; - } - } - }, - ), - ), ], - ], - ), - ); - } - return Container(); - }, - ), - ExceptionWidget( - appImplementations.error, - onlyIcon: isQuickBar, - onRetry: _appsBloc.refresh, - ), - CustomLinearProgressIndicator( - visible: appImplementations.loading, - ), - if (appImplementations.data != null) ...[ - for (final appImplementation in appImplementations.data!) ...[ - StreamBuilder( - stream: appImplementation.getUnreadCounter(_appsBloc) ?? - BehaviorSubject.seeded(0), - builder: (final context, final unreadCounterSnapshot) { - final unreadCount = unreadCounterSnapshot.data ?? 0; - if (isQuickBar) { - return Tooltip( - message: appImplementation.name(context), - child: IconButton( - onPressed: () async { - await _appsBloc.setActiveApp(appImplementation.id); - }, - icon: AppImplementationIcon( - appImplementation: appImplementation, - unreadCount: unreadCount, - color: Theme.of(context).colorScheme.primary, - ), - ), ); } - return ListTile( - key: Key('app-${appImplementation.id}'), - title: Row( + return DrawerHeader( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(appImplementation.name(context)), - if (unreadCount > 0) ...[ - Text( - unreadCount.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - fontSize: 14, + if (capabilities.data != null) ...[ + if (capabilities.data!.capabilities.theming?.name != null) ...[ + Text( + capabilities.data!.capabilities.theming!.name!, + style: DefaultTextStyle.of(context).style.copyWith( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ], + if (capabilities.data!.capabilities.theming?.logo != null) ...[ + Flexible( + child: CachedURLImage( + url: capabilities.data!.capabilities.theming!.logo!, + ), + ), + ], + ] else ...[ + ExceptionWidget( + capabilities.error, + onRetry: _capabilitiesBloc.refresh, + ), + CustomLinearProgressIndicator( + visible: capabilities.loading, + ), + ], + if (accounts.length != 1) ...[ + DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + dropdownColor: Theme.of(context).colorScheme.primary, + iconEnabledColor: + Theme.of(context).colorScheme.onBackground, + value: widget.account.id, + items: accounts + .map>( + (final account) => DropdownMenuItem( + value: account.id, + child: AccountTile( + account: account, + dense: true, + textColor: + Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ) + .toList(), + onChanged: (final id) { + for (final account in accounts) { + if (account.id == id) { + _accountsBloc.setActiveAccount(account); + break; + } + } + }, ), ), ], ], ), - leading: appImplementation.buildIcon(context), - minLeadingWidth: 0, - onTap: () async { - await _appsBloc.setActiveApp(appImplementation.id); - if (navigationMode == NavigationMode.drawer) { - // Don't pop when the drawer is always shown - if (!mounted) { - return; - } - Navigator.of(context).pop(); - } - }, ); - }, - ), + } + return Container(); + }, + ), + ExceptionWidget( + appImplementations.error, + onlyIcon: isQuickBar, + onRetry: _appsBloc.refresh, + ), + CustomLinearProgressIndicator( + visible: appImplementations.loading, + ), + if (appImplementations.data != null) ...[ + for (final appImplementation in appImplementations.data!) ...[ + StreamBuilder( + stream: appImplementation.getUnreadCounter(_appsBloc) ?? + BehaviorSubject.seeded(0), + builder: (final context, final unreadCounterSnapshot) { + final unreadCount = unreadCounterSnapshot.data ?? 0; + if (isQuickBar) { + return Tooltip( + message: appImplementation.name(context), + child: IconButton( + onPressed: () async { + await _appsBloc.setActiveApp(appImplementation.id); + }, + icon: AppImplementationIcon( + appImplementation: appImplementation, + unreadCount: unreadCount, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } + return ListTile( + key: Key('app-${appImplementation.id}'), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appImplementation.name(context)), + if (unreadCount > 0) ...[ + Text( + unreadCount.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ], + ), + leading: appImplementation.buildIcon(context), + minLeadingWidth: 0, + onTap: () async { + await _appsBloc.setActiveApp(appImplementation.id); + if (navigationMode == NavigationMode.drawer) { + // Don't pop when the drawer is always shown + if (!mounted) { + return; + } + Navigator.of(context).pop(); + } + }, + ); + }, + ), + ], ], ], - ], + ), ), ), - ), - if (isQuickBar) ...[ - IconButton( - icon: Icon( - Icons.settings, - color: Theme.of(context).appBarTheme.foregroundColor, + if (isQuickBar) ...[ + IconButton( + icon: Icon( + Icons.settings, + color: Theme.of(context).appBarTheme.foregroundColor, + ), + onPressed: _openSettings, ), - onPressed: _openSettings, - ), - ] else ...[ - ListTile( - key: const Key('settings'), - title: Text(AppLocalizations.of(context).settings), - leading: const Icon(Icons.settings), - minLeadingWidth: 0, - onTap: () async { - if (navigationMode == NavigationMode.drawer) { - Navigator.of(context).pop(); - } - await _openSettings(); - }, - ), + ] else ...[ + ListTile( + key: const Key('settings'), + title: Text(AppLocalizations.of(context).settings), + leading: const Icon(Icons.settings), + minLeadingWidth: 0, + onTap: () async { + if (navigationMode == NavigationMode.drawer) { + Navigator.of(context).pop(); + } + await _openSettings(); + }, + ), + ], ], - ], + ), ), - ), - ); + ); - return Scaffold( - resizeToAvoidBottomInset: false, - body: Row( - children: [ - if (navigationMode == NavigationMode.drawerAlwaysVisible) ...[ - drawer, - ], - Expanded( - child: Scaffold( - key: _scaffoldKey, - resizeToAvoidBottomInset: false, - drawer: navigationMode == NavigationMode.drawer ? drawer : null, - appBar: AppBar( - scrolledUnderElevation: navigationMode != NavigationMode.drawer ? 0 : null, - automaticallyImplyLeading: navigationMode == NavigationMode.drawer, - leadingWidth: isQuickBar ? kQuickBarWidth : null, - leading: isQuickBar - ? Container( - padding: const EdgeInsets.all(5), - child: capabilities.data?.capabilities.theming?.logo != null - ? CachedURLImage( - url: capabilities.data!.capabilities.theming!.logo!, - ) - : null, - ) - : null, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[ - Flexible( - child: Text( - appImplementations.data! - .singleWhere((final a) => a.id == activeAppIDSnapshot.data!) - .name(context), + return Scaffold( + resizeToAvoidBottomInset: false, + body: Row( + children: [ + if (navigationMode == NavigationMode.drawerAlwaysVisible) ...[ + drawer, + ], + Expanded( + child: Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: false, + drawer: navigationMode == NavigationMode.drawer ? drawer : null, + appBar: AppBar( + scrolledUnderElevation: navigationMode != NavigationMode.drawer ? 0 : null, + automaticallyImplyLeading: navigationMode == NavigationMode.drawer, + leadingWidth: isQuickBar ? kQuickBarWidth : null, + leading: isQuickBar + ? Container( + padding: const EdgeInsets.all(5), + child: capabilities.data?.capabilities.theming?.logo != null + ? CachedURLImage( + url: capabilities.data!.capabilities.theming!.logo!, + ) + : null, + ) + : null, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[ + Flexible( + child: Text( + appImplementations.data! + .singleWhere((final a) => a.id == activeAppIDSnapshot.data!) + .name(context), + ), ), - ), - ], - if (appImplementations.error != null) ...[ - const SizedBox( - width: 8, - ), - Icon( - Icons.error_outline, - size: 30, - color: Theme.of(context).colorScheme.onPrimary, - ), - ], - if (appImplementations.loading) ...[ - const SizedBox( - width: 8, - ), - Expanded( - child: CustomLinearProgressIndicator( - color: Theme.of(context).appBarTheme.foregroundColor, + ], + if (appImplementations.error != null) ...[ + const SizedBox( + width: 8, ), - ), + Icon( + Icons.error_outline, + size: 30, + color: Theme.of(context).colorScheme.onPrimary, + ), + ], + if (appImplementations.loading) ...[ + const SizedBox( + width: 8, + ), + Expanded( + child: CustomLinearProgressIndicator( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ], ], + ), + if (accounts.length > 1) ...[ + Text( + account.client.humanReadableID, + style: Theme.of(context).textTheme.bodySmall, + ), ], - ), - if (accounts.length > 1) ...[ - Text( - account.client.humanReadableID, - style: Theme.of(context).textTheme.bodySmall, + ], + ), + actions: [ + if (notificationsAppImplementation.data != null) ...[ + StreamBuilder( + stream: notificationsAppImplementation.data!.getUnreadCounter(_appsBloc), + builder: (final context, final unreadCounterSnapshot) { + final unreadCount = unreadCounterSnapshot.data ?? 0; + return IconButton( + key: Key('app-${notificationsAppImplementation.data!.id}'), + icon: AppImplementationIcon( + appImplementation: notificationsAppImplementation.data!, + unreadCount: unreadCount, + color: unreadCount > 0 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onBackground, + width: kAvatarSize * 2 / 3, + height: kAvatarSize * 2 / 3, + ), + onPressed: () async { + await _openNotifications( + notificationsAppImplementation.data!, + accounts, + account, + ); + }, + ); + }, ), ], - ], - ), - actions: [ - if (notificationsAppImplementation.data != null) ...[ - StreamBuilder( - stream: notificationsAppImplementation.data!.getUnreadCounter(_appsBloc), - builder: (final context, final unreadCounterSnapshot) { - final unreadCount = unreadCounterSnapshot.data ?? 0; - return IconButton( - key: Key('app-${notificationsAppImplementation.data!.id}'), - icon: AppImplementationIcon( - appImplementation: notificationsAppImplementation.data!, - unreadCount: unreadCount, - color: unreadCount > 0 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onBackground, - width: kAvatarSize * 2 / 3, - height: kAvatarSize * 2 / 3, + IconButton( + icon: IntrinsicWidth( + child: AccountAvatar( + account: account, + ), + ), + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => AccountSettingsPage( + bloc: _accountsBloc, + account: account, + ), ), - onPressed: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (final context) => Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(notificationsAppImplementation.data!.name(context)), - if (accounts.length > 1) ...[ - Text( - account.client.humanReadableID, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ], - ), - ), - body: notificationsAppImplementation.data! - .buildPage(context, _appsBloc), - ), - ), - ); - }, ); }, ), ], - IconButton( - icon: IntrinsicWidth( - child: AccountAvatar( - account: account, - ), - ), - onPressed: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (final context) => AccountSettingsPage( - bloc: accountsBloc, - account: account, + ), + body: Row( + children: [ + if (navigationMode == NavigationMode.quickBar) ...[ + drawer, + ], + Expanded( + child: Column( + children: [ + ExceptionWidget( + appImplementations.error, + onRetry: _appsBloc.refresh, ), - ), - ); - }, - ), - ], - ), - body: Row( - children: [ - if (navigationMode == NavigationMode.quickBar) ...[ - drawer, - ], - Expanded( - child: Column( - children: [ - ExceptionWidget( - appImplementations.error, - onRetry: _appsBloc.refresh, - ), - if (appImplementations.data != null) ...[ - if (appImplementations.data!.isEmpty) ...[ - Expanded( - child: Center( - child: Text( - AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound, - textAlign: TextAlign.center, - ), - ), - ), - ] else ...[ - if (activeAppIDSnapshot.hasData) ...[ + if (appImplementations.data != null) ...[ + if (appImplementations.data!.isEmpty) ...[ Expanded( - child: appImplementations.data! - .singleWhere((final a) => a.id == activeAppIDSnapshot.data!) - .buildPage(context, _appsBloc), + child: Center( + child: Text( + AppLocalizations.of(context) + .errorNoCompatibleNextcloudAppsFound, + textAlign: TextAlign.center, + ), + ), ), + ] else ...[ + if (activeAppIDSnapshot.hasData) ...[ + Expanded( + child: appImplementations.data! + .singleWhere((final a) => a.id == activeAppIDSnapshot.data!) + .buildPage(context, _appsBloc), + ), + ], ], ], ], - ], + ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - ); - } - return Container(); - }, + ], + ), + ); + } + return Container(); + }, + ), ), ), ), ), ), ), - ), - ); - } + ); } diff --git a/packages/neon/lib/src/pages/settings.dart b/packages/neon/lib/src/pages/settings.dart index 0ad7ad0a..eeb42dec 100644 --- a/packages/neon/lib/src/pages/settings.dart +++ b/packages/neon/lib/src/pages/settings.dart @@ -119,13 +119,6 @@ class _SettingsPageState extends State { SettingsCategory( title: Text(AppLocalizations.of(context).optionsCategoryPushNotifications), tiles: [ - TextSettingsTile( - text: AppLocalizations.of(context).globalOptionsPushNotificationsNotice, - style: const TextStyle( - fontWeight: FontWeight.w300, - fontStyle: FontStyle.italic, - ), - ), if (pushNotificationsEnabledEnabledSnapshot.data != null && !pushNotificationsEnabledEnabledSnapshot.data!) ...[ TextSettingsTile( diff --git a/packages/neon/lib/src/utils/global.dart b/packages/neon/lib/src/utils/global.dart index 32e5e174..a8fbaafa 100644 --- a/packages/neon/lib/src/utils/global.dart +++ b/packages/neon/lib/src/utils/global.dart @@ -2,5 +2,5 @@ part of '../neon.dart'; class Global { static Function(String accountID)? onPushNotificationReceived; - static Function(PushNotificationWithAccountID notification)? onPushNotificationClicked; + static Function(PushNotification notification)? onPushNotificationClicked; } diff --git a/packages/neon/lib/src/utils/global_options.dart b/packages/neon/lib/src/utils/global_options.dart index 529b410f..5df411c4 100644 --- a/packages/neon/lib/src/utils/global_options.dart +++ b/packages/neon/lib/src/utils/global_options.dart @@ -10,15 +10,25 @@ class GlobalOptions { }); _pushNotificationsDistributorsSubject.listen((final distributors) async { - _pushNotificationsEnabledEnabledSubject.add(distributors.isNotEmpty); - await _setDefaultDistributor(); + final allowed = distributors.isNotEmpty; + _pushNotificationsEnabledEnabledSubject.add(allowed); + if (!allowed) { + await pushNotificationsEnabled.set(false); + } }); pushNotificationsEnabled.stream.listen((final enabled) async { - if (!enabled) { + if (enabled) { + final response = await Permission.notification.request(); + if (response.isPermanentlyDenied) { + _pushNotificationsEnabledEnabledSubject.add(false); + } + if (!response.isGranted) { + await pushNotificationsEnabled.set(false); + } + } else { await pushNotificationsDistributor.set(null); } - await _setDefaultDistributor(); }); rememberLastUsedAccount.stream.listen((final remember) async { @@ -48,6 +58,8 @@ class GlobalOptions { AppLocalizations.of(context).globalOptionsPushNotificationsDistributorFirebaseEmbedded, 'com.github.gotify.up': (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsDistributorGotifyUP, + 'eu.siacs.conversations': (final context) => + AppLocalizations.of(context).globalOptionsPushNotificationsDistributorConversations, 'io.heckel.ntfy': (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsDistributorNtfy, 'org.unifiedpush.distributor.fcm': (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsDistributorFCMUP, @@ -105,17 +117,6 @@ class GlobalOptions { }); } - Future _setDefaultDistributor() async { - if ((pushNotificationsEnabled.enabled.valueOrNull ?? false) && - pushNotificationsEnabled.value && - pushNotificationsDistributor.values.hasValue && - pushNotificationsDistributor.values.value.isNotEmpty && - pushNotificationsDistributor.stream.hasValue && - pushNotificationsDistributor.value == null) { - await pushNotificationsDistributor.set((await pushNotificationsDistributor.values.first).keys.toList()[0]); - } - } - late final themeMode = SelectOption( storage: _storage, key: 'theme-mode', @@ -147,7 +148,7 @@ class GlobalOptions { storage: _storage, key: 'push-notifications-enabled', label: (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsEnabled, - defaultValue: BehaviorSubject.seeded(true), + defaultValue: BehaviorSubject.seeded(false), enabled: _pushNotificationsEnabledEnabledSubject, ); diff --git a/packages/neon/lib/src/utils/push_utils.dart b/packages/neon/lib/src/utils/push_utils.dart index 84aefb0b..2ed47a5f 100644 --- a/packages/neon/lib/src/utils/push_utils.dart +++ b/packages/neon/lib/src/utils/push_utils.dart @@ -27,7 +27,7 @@ class PushUtils { android: const AndroidInitializationSettings('@mipmap/ic_launcher'), linux: LinuxInitializationSettings( defaultActionName: 'Open', - defaultIcon: AssetsLinuxIcon('assets/logo_harbour.svg'), + defaultIcon: AssetsLinuxIcon('assets/logo_neon.svg'), ), ), onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, @@ -35,14 +35,14 @@ class PushUtils { return localNotificationsPlugin; } - static Future onMessage(final Uint8List message, final String instance) async { + static Future onMessage(final Uint8List messages, final String instance) async { WidgetsFlutterBinding.ensureInitialized(); final localNotificationsPlugin = await initLocalNotifications( onDidReceiveNotificationResponse: (final notification) async { if (Global.onPushNotificationClicked != null && notification.payload != null) { await Global.onPushNotificationClicked!( - PushNotificationWithAccountID.fromJson( + PushNotification.fromJson( json.decode(notification.payload!) as Map, ), ); @@ -52,82 +52,87 @@ class PushUtils { final sharedPreferences = await SharedPreferences.getInstance(); final keypair = await loadRSAKeypair(AppStorage('notifications', sharedPreferences)); - final data = json.decode(utf8.decode(message)) as Map; - final notification = NextcloudNotificationsPushNotification( - accountID: instance, - priority: data['priority']! as String, - type: data['type']! as String, - subject: decryptPushNotificationSubject(keypair.privateKey, data['subject']! as String), - ); - if (notification.subject.delete ?? false) { - await localNotificationsPlugin.cancel(_getNotificationID(instance, notification)); - return; - } - if (notification.subject.deleteAll ?? false) { - await localNotificationsPlugin.cancelAll(); - return; - } - if (notification.type == 'background') { - debugPrint('Got unknown background notification $notification.subject'); - return; - } + for (final message in Uri(query: utf8.decode(messages)).queryParameters.values) { + final data = json.decode(message) as Map; + final notification = PushNotification( + accountID: instance, + priority: data['priority']! as String, + type: data['type']! as String, + subject: decryptPushNotificationSubject(keypair.privateKey, data['subject']! as String), + ); - final localizations = await appLocalizationsFromSystem(); + if (notification.subject.delete ?? false) { + await localNotificationsPlugin.cancel(_getNotificationID(instance, notification)); + return; + } + if (notification.subject.deleteAll ?? false) { + await localNotificationsPlugin.cancelAll(); + return; + } + if (notification.type == 'background') { + debugPrint('Got unknown background notification ${json.encode(notification.toJson())}'); + return; + } - final platform = await getNeonPlatform(); - final cache = Cache(platform); - await cache.init(); - final requestManager = RequestManager(cache); - final allAppImplementations = getAppImplementations(sharedPreferences, requestManager, platform); + final localizations = await appLocalizationsFromSystem(); - final matchingAppImplementations = - allAppImplementations.where((final a) => a.id == notification.subject.app).toList(); - late AppImplementation app; - if (matchingAppImplementations.isNotEmpty) { - app = matchingAppImplementations.single; - } else { - app = allAppImplementations.singleWhere((final a) => a.id == 'notifications'); - } + final platform = await getNeonPlatform(); + final cache = Cache(platform); + await cache.init(); + final requestManager = RequestManager(cache); + final allAppImplementations = getAppImplementations(sharedPreferences, requestManager, platform); - final appName = app.nameFromLocalization(localizations); - - await localNotificationsPlugin.show( - _getNotificationID(instance, notification), - appName, - notification.subject.subject, - NotificationDetails( - android: AndroidNotificationDetails( - app.id, - appName, - groupKey: 'app_${app.id}', - icon: '@mipmap/app_${app.id}', - color: themePrimaryColor, - category: notification.type == 'voip' ? AndroidNotificationCategory.call : null, - importance: Importance.max, - priority: notification.priority == 'high' - ? (notification.type == 'voip' ? Priority.max : Priority.high) - : Priority.defaultPriority, + final matchingAppImplementations = + allAppImplementations.where((final a) => a.id == notification.subject.app).toList(); + late AppImplementation app; + if (matchingAppImplementations.isNotEmpty) { + app = matchingAppImplementations.single; + } else { + app = allAppImplementations.singleWhere((final a) => a.id == 'notifications'); + } + + final appName = app.nameFromLocalization(localizations); + + await localNotificationsPlugin.show( + _getNotificationID(instance, notification), + appName, + notification.subject.subject, + NotificationDetails( + android: AndroidNotificationDetails( + app.id, + appName, + groupKey: 'app_${app.id}', + icon: '@mipmap/app_${app.id}', + color: themePrimaryColor, + category: notification.type == 'voip' ? AndroidNotificationCategory.call : null, + importance: Importance.max, + priority: notification.priority == 'high' + ? (notification.type == 'voip' ? Priority.max : Priority.high) + : Priority.defaultPriority, + ), + linux: LinuxNotificationDetails( + icon: AssetsLinuxIcon('assets/apps/${app.id}.svg'), + urgency: notification.type == 'voip' ? LinuxNotificationUrgency.critical : LinuxNotificationUrgency.normal, + ), ), - linux: LinuxNotificationDetails( - icon: AssetsLinuxIcon('assets/apps/${app.id}.svg'), - urgency: notification.type == 'voip' ? LinuxNotificationUrgency.critical : LinuxNotificationUrgency.normal, + payload: json.encode( + PushNotification( + accountID: instance, + priority: notification.priority, + type: notification.type, + subject: notification.subject, + ).toJson(), ), - ), - payload: json.encode( - PushNotificationWithAccountID( - notification: notification, - accountID: instance, - ).toJson(), - ), - ); + ); - Global.onPushNotificationReceived?.call(instance); + Global.onPushNotificationReceived?.call(instance); + } } static int _getNotificationID( final String instance, - final NextcloudNotificationsPushNotification notification, + final PushNotification notification, ) => sha256.convert(utf8.encode('$instance${notification.subject.nid}')).bytes.reduce((final a, final b) => a + b); } diff --git a/packages/nextcloud/lib/src/helpers.dart b/packages/nextcloud/lib/src/helpers.dart index b4f116c3..a6b4895f 100644 --- a/packages/nextcloud/lib/src/helpers.dart +++ b/packages/nextcloud/lib/src/helpers.dart @@ -2,64 +2,24 @@ part of '../nextcloud.dart'; -extension UserDetailsDisplayName on openapi.NextcloudProvisioningApiUserDetails { - /// This is used to work around an API change that wasn't made for every endpoint - /// See https://github.com/nextcloud/server/commit/5086335643b6181284ee50f57b95525002842992 - String? getDisplayName() => displayname ?? displayName; -} - -extension NextcloudNotificationsPushProxy on NextcloudNotificationsClient { - /// Registers a device at the push proxy server - Future registerDeviceAtPushProxy( - final String pushToken, - final NextcloudNotificationsPushServerSubscription subscription, - final String proxyServer, - ) async { - final request = await HttpClient().postUrl(Uri.parse('${proxyServer}devices')) - ..followRedirects = false - ..persistentConnection = true; - - request.headers.add(HttpHeaders.contentTypeHeader, 'application/x-www-form-urlencoded'); - - request.add( - utf8.encode( - Uri( - queryParameters: { - 'pushToken': pushToken, - 'deviceIdentifier': subscription.deviceIdentifier, - 'deviceIdentifierSignature': subscription.signature, - 'userPublicKey': subscription.publicKey, - }, - ).query, - ), - ); - - final response = await request.close(); - - if (response.statusCode != 200) { - // coverage:ignore-start - throw NextcloudApiException( - response.statusCode, - {}, // TODO - await response.bodyBytes, - ); - // coverage:ignore-end - } - } - - /// Generates the push token hash which is just sha512 - String generatePushTokenHash(final String pushToken) => sha512.convert(utf8.encode(pushToken)).toString(); -} +/// Generates the push token hash which is just sha512 +String generatePushTokenHash(final String pushToken) => sha512.convert(utf8.encode(pushToken)).toString(); /// Decrypts the subject of a push notification -NextcloudNotificationsPushNotificationDecryptedSubject decryptPushNotificationSubject( +NextcloudNotificationsNotificationDecryptedSubject decryptPushNotificationSubject( final RSAPrivateKey privateKey, final String subject, ) => - NextcloudNotificationsPushNotificationDecryptedSubject.fromJson( + NextcloudNotificationsNotificationDecryptedSubject.fromJson( json.decode(privateKey.decrypt(subject)) as Map, ); +extension UserDetailsDisplayName on openapi.NextcloudProvisioningApiUserDetails { + /// This is used to work around an API change that wasn't made for every endpoint + /// See https://github.com/nextcloud/server/commit/5086335643b6181284ee50f57b95525002842992 + String? getDisplayName() => displayname ?? displayName; +} + /// See https://github.com/nextcloud/news/blob/4a107b3d53c4fe651ac704251b99e04a53cd587f/lib/Db/ListType.php enum NewsListType { feed(0), diff --git a/packages/nextcloud/lib/src/nextcloud.openapi.dart b/packages/nextcloud/lib/src/nextcloud.openapi.dart index 54a2ecb6..cfd669c4 100644 --- a/packages/nextcloud/lib/src/nextcloud.openapi.dart +++ b/packages/nextcloud/lib/src/nextcloud.openapi.dart @@ -5178,8 +5178,8 @@ class NextcloudUserStatusPredefinedStatuses { } @JsonSerializable() -class NextcloudNotificationsPushNotificationDecryptedSubject { - NextcloudNotificationsPushNotificationDecryptedSubject({ +class NextcloudNotificationsNotificationDecryptedSubject { + NextcloudNotificationsNotificationDecryptedSubject({ this.nid, this.app, this.subject, @@ -5190,13 +5190,13 @@ class NextcloudNotificationsPushNotificationDecryptedSubject { }); // coverage:ignore-start - factory NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(Map json) => - _$NextcloudNotificationsPushNotificationDecryptedSubjectFromJson(json); + factory NextcloudNotificationsNotificationDecryptedSubject.fromJson(Map json) => + _$NextcloudNotificationsNotificationDecryptedSubjectFromJson(json); // coverage:ignore-end // coverage:ignore-start - factory NextcloudNotificationsPushNotificationDecryptedSubject.fromJsonString(String data) => - NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(json.decode(data) as Map); + factory NextcloudNotificationsNotificationDecryptedSubject.fromJsonString(String data) => + NextcloudNotificationsNotificationDecryptedSubject.fromJson(json.decode(data) as Map); // coverage:ignore-end final int? nid; @@ -5215,43 +5215,9 @@ class NextcloudNotificationsPushNotificationDecryptedSubject { final bool? deleteAll; // coverage:ignore-start - Map toJson() => _$NextcloudNotificationsPushNotificationDecryptedSubjectToJson(this); + Map toJson() => _$NextcloudNotificationsNotificationDecryptedSubjectToJson(this); // coverage:ignore-end - static String? toJsonString(NextcloudNotificationsPushNotificationDecryptedSubject? data) => - data == null ? null : json.encode(data.toJson()); -} - -@JsonSerializable() -class NextcloudNotificationsPushNotification { - NextcloudNotificationsPushNotification({ - required this.accountID, - required this.priority, - required this.type, - required this.subject, - }); - - // coverage:ignore-start - factory NextcloudNotificationsPushNotification.fromJson(Map json) => - _$NextcloudNotificationsPushNotificationFromJson(json); - // coverage:ignore-end - - // coverage:ignore-start - factory NextcloudNotificationsPushNotification.fromJsonString(String data) => - NextcloudNotificationsPushNotification.fromJson(json.decode(data) as Map); - // coverage:ignore-end - - final String accountID; - - final String priority; - - final String type; - - final NextcloudNotificationsPushNotificationDecryptedSubject subject; - - // coverage:ignore-start - Map toJson() => _$NextcloudNotificationsPushNotificationToJson(this); - // coverage:ignore-end - static String? toJsonString(NextcloudNotificationsPushNotification? data) => + static String? toJsonString(NextcloudNotificationsNotificationDecryptedSubject? data) => data == null ? null : json.encode(data.toJson()); } @@ -5773,15 +5739,10 @@ final _deserializers = { NextcloudUserStatusPredefinedStatus.fromJson(data as Map), List: (final data) => (data as List).map((final e) => NextcloudUserStatusPredefinedStatus.fromJson(e as Map)).toList(), - NextcloudNotificationsPushNotification: (final data) => - NextcloudNotificationsPushNotification.fromJson(data as Map), - List: (final data) => (data as List) - .map((final e) => NextcloudNotificationsPushNotification.fromJson(e as Map)) - .toList(), - NextcloudNotificationsPushNotificationDecryptedSubject: (final data) => - NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(data as Map), - List: (final data) => (data as List) - .map((final e) => NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(e as Map)) + NextcloudNotificationsNotificationDecryptedSubject: (final data) => + NextcloudNotificationsNotificationDecryptedSubject.fromJson(data as Map), + List: (final data) => (data as List) + .map((final e) => NextcloudNotificationsNotificationDecryptedSubject.fromJson(e as Map)) .toList(), }; @@ -6036,11 +5997,8 @@ final _serializers = { List: (final data) => data.map((final e) => e.toJson()).toList(), NextcloudUserStatusPredefinedStatus: (final data) => data.toJson(), List: (final data) => data.map((final e) => e.toJson()).toList(), - NextcloudNotificationsPushNotification: (final data) => data.toJson(), - List: (final data) => data.map((final e) => e.toJson()).toList(), - NextcloudNotificationsPushNotificationDecryptedSubject: (final data) => data.toJson(), - List: (final data) => - data.map((final e) => e.toJson()).toList(), + NextcloudNotificationsNotificationDecryptedSubject: (final data) => data.toJson(), + List: (final data) => data.map((final e) => e.toJson()).toList(), }; T deserializeNextcloud(final dynamic data) => _deserializers[T]!(data) as T; diff --git a/packages/nextcloud/lib/src/nextcloud.openapi.g.dart b/packages/nextcloud/lib/src/nextcloud.openapi.g.dart index 3b45fd43..c5bb7fa6 100644 --- a/packages/nextcloud/lib/src/nextcloud.openapi.g.dart +++ b/packages/nextcloud/lib/src/nextcloud.openapi.g.dart @@ -2193,13 +2193,13 @@ Map _$NextcloudUserStatusPredefinedStatusesToJson(NextcloudUser 'ocs': instance.ocs.toJson(), }; -NextcloudNotificationsPushNotificationDecryptedSubject _$NextcloudNotificationsPushNotificationDecryptedSubjectFromJson( +NextcloudNotificationsNotificationDecryptedSubject _$NextcloudNotificationsNotificationDecryptedSubjectFromJson( Map json) { $checkKeys( json, allowedKeys: const ['nid', 'app', 'subject', 'type', 'id', 'delete', 'delete-all'], ); - return NextcloudNotificationsPushNotificationDecryptedSubject( + return NextcloudNotificationsNotificationDecryptedSubject( nid: json['nid'] as int?, app: json['app'] as String?, subject: json['subject'] as String?, @@ -2210,8 +2210,8 @@ NextcloudNotificationsPushNotificationDecryptedSubject _$NextcloudNotificationsP ); } -Map _$NextcloudNotificationsPushNotificationDecryptedSubjectToJson( - NextcloudNotificationsPushNotificationDecryptedSubject instance) => +Map _$NextcloudNotificationsNotificationDecryptedSubjectToJson( + NextcloudNotificationsNotificationDecryptedSubject instance) => { 'nid': instance.nid, 'app': instance.app, @@ -2221,24 +2221,3 @@ Map _$NextcloudNotificationsPushNotificationDecryptedSubjectToJ 'delete': instance.delete, 'delete-all': instance.deleteAll, }; - -NextcloudNotificationsPushNotification _$NextcloudNotificationsPushNotificationFromJson(Map json) { - $checkKeys( - json, - allowedKeys: const ['accountID', 'priority', 'type', 'subject'], - ); - return NextcloudNotificationsPushNotification( - accountID: json['accountID'] as String, - priority: json['priority'] as String, - type: json['type'] as String, - subject: NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(json['subject'] as Map), - ); -} - -Map _$NextcloudNotificationsPushNotificationToJson(NextcloudNotificationsPushNotification instance) => - { - 'accountID': instance.accountID, - 'priority': instance.priority, - 'type': instance.type, - 'subject': instance.subject.toJson(), - }; diff --git a/packages/nextcloud/lib/src/nextcloud.openapi.json b/packages/nextcloud/lib/src/nextcloud.openapi.json index d83dd6bb..177e7066 100644 --- a/packages/nextcloud/lib/src/nextcloud.openapi.json +++ b/packages/nextcloud/lib/src/nextcloud.openapi.json @@ -1471,30 +1471,7 @@ } } }, - "NotificationsPushNotification": { - "type": "object", - "required": [ - "accountID", - "priority", - "type", - "subject" - ], - "properties": { - "accountID": { - "type": "string" - }, - "priority": { - "type": "string" - }, - "type": { - "type": "string" - }, - "subject": { - "$ref": "#/components/schemas/NotificationsPushNotificationDecryptedSubject" - } - } - }, - "NotificationsPushNotificationDecryptedSubject": { + "NotificationsNotificationDecryptedSubject": { "type": "object", "properties": { "nid": { diff --git a/packages/nextcloud/pubspec.yaml b/packages/nextcloud/pubspec.yaml index d9e2b3d0..e412c8bd 100644 --- a/packages/nextcloud/pubspec.yaml +++ b/packages/nextcloud/pubspec.yaml @@ -6,7 +6,7 @@ environment: dependencies: cookie_jar: ^3.0.1 - crypto: ^3.0.1 + crypto: ^3.0.2 crypton: ^2.0.5 intl: ^0.17.0 json_annotation: ^4.7.0 @@ -19,8 +19,6 @@ dev_dependencies: dynamite: path: ../dynamite json_serializable: ^6.3.2 - nextcloud_push_proxy: - path: ../nextcloud_push_proxy nextcloud_test: path: ../nextcloud_test nit_picking: diff --git a/packages/nextcloud/test/notifications.dart b/packages/nextcloud/test/notifications.dart index b446525d..0d10ee1c 100644 --- a/packages/nextcloud/test/notifications.dart +++ b/packages/nextcloud/test/notifications.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:nextcloud/nextcloud.dart'; -import 'package:nextcloud_push_proxy/nextcloud_push_proxy.dart'; import 'package:nextcloud_test/nextcloud_test.dart'; import 'package:test/test.dart'; @@ -117,95 +115,12 @@ Future run(final DockerImage image) async { // ignore: avoid_redundant_argument_values RSAKeypair generateKeypair() => RSAKeypair.fromRandom(keySize: 2048); - test('Register device and receive notification', () async { - const pushToken = '789'; - final keypair = generateKeypair(); - - final pushProxy = NextcloudPushProxy(); - - late int port; - while (true) { - port = randomPort(); - try { - await pushProxy.create( - logging: false, - port: port, - ); - break; - } on SocketException catch (e) { - if (e.osError?.errorCode != 98) { - rethrow; - } - } - } - - final subscription = (await client.notifications.registerDevice( - pushTokenHash: client.notifications.generatePushTokenHash(pushToken), - devicePublicKey: keypair.publicKey.toFormattedPEM(), - proxyServer: 'http://host.docker.internal:$port/', - )) - .ocs - .data; - expect(subscription.publicKey, hasLength(451)); - RSAPublicKey.fromPEM(subscription.publicKey); - expect(subscription.deviceIdentifier, isNotEmpty); - expect(subscription.signature, isNotEmpty); - expect(subscription.message, isNull); - - final deviceCompleter = Completer(); - final notificationCompleter = Completer(); - - pushProxy.onNewDevice.listen((final device) async { - expect(device.pushToken, pushToken); - expect(device.deviceIdentifier, isNotEmpty); - expect(device.deviceIdentifierSignature, isNotEmpty); - expect(device.userPublicKey, isNotEmpty); - - deviceCompleter.complete(); - }); - pushProxy.onNewNotification.listen((final notification) async { - expect(notification.deviceIdentifier, subscription.deviceIdentifier); - expect(notification.pushTokenHash, client.notifications.generatePushTokenHash(pushToken)); - expect(notification.subject, isNotEmpty); - expect(notification.signature, isNotEmpty); - expect(notification.priority, 'normal'); - expect(notification.type, 'alert'); - - final decryptedSubject = decryptPushNotificationSubject( - keypair.privateKey, - notification.subject, - ); - expect(decryptedSubject.nid, isNotNull); - expect(decryptedSubject.app, 'admin_notifications'); - expect(decryptedSubject.subject, '123'); - expect(decryptedSubject.type, 'admin_notifications'); - expect(decryptedSubject.id, isNotEmpty); - - notificationCompleter.complete(); - }); - - await client.notifications.registerDeviceAtPushProxy( - pushToken, - subscription, - 'http://localhost:$port/', - ); - await client.notifications.sendAdminNotification( - userId: 'admin', - shortMessage: '123', - longMessage: '456', - ); - - await deviceCompleter.future; - await notificationCompleter.future; - await pushProxy.close(); - }); - - test('Remove push device', () async { + test('Register and remove push device', () async { const pushToken = '789'; final keypair = generateKeypair(); final subscription = (await client.notifications.registerDevice( - pushTokenHash: client.notifications.generatePushTokenHash(pushToken), + pushTokenHash: generatePushTokenHash(pushToken), devicePublicKey: keypair.publicKey.toFormattedPEM(), proxyServer: 'https://example.com/', )) diff --git a/packages/nextcloud_push_proxy/.gitignore b/packages/nextcloud_push_proxy/.gitignore deleted file mode 100644 index 1a1d9ab8..00000000 --- a/packages/nextcloud_push_proxy/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -# Files and directories created by pub. -.dart_tool/ -.packages - -# Conventional directory for build outputs. -build/ - -# Omit committing pubspec.lock for library packages; see -# https://dart.dev/guides/libraries/private-files#pubspeclock. -pubspec.lock - -tmp/ diff --git a/packages/nextcloud_push_proxy/Dockerfile b/packages/nextcloud_push_proxy/Dockerfile deleted file mode 100644 index 7efde200..00000000 --- a/packages/nextcloud_push_proxy/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM dart:stable as builder - -WORKDIR /app - -ADD pubspec.yaml . -RUN dart pub get - - -ADD lib ./lib -ADD bin ./bin -RUN dart compile exe bin/unified_push.dart -o bin/nextcloud-push-proxy - -FROM debian:bullseye-slim - -COPY --from=builder /app/bin/nextcloud-push-proxy /usr/local/bin/ - -CMD ["nextcloud-push-proxy", "/data/devices.json"] diff --git a/packages/nextcloud_push_proxy/LICENSE b/packages/nextcloud_push_proxy/LICENSE deleted file mode 120000 index 30cff740..00000000 --- a/packages/nextcloud_push_proxy/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/nextcloud_push_proxy/analysis_options.yaml b/packages/nextcloud_push_proxy/analysis_options.yaml deleted file mode 100644 index 0ed2fb9f..00000000 --- a/packages/nextcloud_push_proxy/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:nit_picking/dart.yaml diff --git a/packages/nextcloud_push_proxy/bin/unified_push.dart b/packages/nextcloud_push_proxy/bin/unified_push.dart deleted file mode 100644 index 860c9298..00000000 --- a/packages/nextcloud_push_proxy/bin/unified_push.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:nextcloud_push_proxy/nextcloud_push_proxy.dart'; - -Future main(final List args) async { - if (args.length != 1) { - throw Exception('Provide the file where to store devices'); - } - final devices = []; - final devicesFile = File(args[0]); - if (devicesFile.existsSync()) { - devices.addAll( - (json.decode(devicesFile.readAsStringSync()) as List) - .map((final d) => PushProxyDevice.fromJson(d as Map)), - ); - } - - final server = NextcloudPushProxy(); - - watchSignals((final signal) async { - print('Got exit signal, shutting down'); - await server.close(); - exit(1); - }); - - await server.create(); - - server.onNewDevice.listen((final device) { - if (!devices.map((final d) => d.pushToken).contains(device.pushToken)) { - devices.add(device); - devicesFile - ..createSync(recursive: true) - ..writeAsString(json.encode(devices.map((final d) => d.toJson()).toList())); - } - }); - - server.onNewNotification.listen((final notification) async { - for (final device in devices) { - if (notification.pushTokenHash == sha512.convert(utf8.encode(device.pushToken)).toString()) { - final request = await HttpClient().postUrl(Uri.parse(device.pushToken)) - ..followRedirects = false - ..persistentConnection = true - ..add(utf8.encode(json.encode(notification.toPushNotificationData()))); - - final response = await request.close(); - if (response.statusCode > 299) { - print('Failed to send notification'); - } - } - } - }); - - print('Listening on *:8080'); -} - -void watchSignals(final Function(ProcessSignal signal) callback) { - for (final signal in [ - ProcessSignal.sighup, - ProcessSignal.sigint, - ProcessSignal.sigterm, - ProcessSignal.sigusr1, - ProcessSignal.sigusr2, - ]) { - signal.watch().listen(callback); - } -} diff --git a/packages/nextcloud_push_proxy/lib/nextcloud_push_proxy.dart b/packages/nextcloud_push_proxy/lib/nextcloud_push_proxy.dart deleted file mode 100644 index 64053b9c..00000000 --- a/packages/nextcloud_push_proxy/lib/nextcloud_push_proxy.dart +++ /dev/null @@ -1,163 +0,0 @@ -// ignore_for_file: public_member_api_docs - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:shelf_router/shelf_router.dart'; - -/// Implements the listening part of a Nextcloud push proxy -class NextcloudPushProxy { - HttpServer? _server; - - late StreamController _onNewDeviceController; - late StreamController _onNewNotificationController; - - Stream? _onNewDeviceStream; - Stream? _onNewNotificationStream; - - /// Listens for new devices - Stream get onNewDevice { - if (_onNewDeviceStream == null) { - throw Exception('Server not created'); - } - return _onNewDeviceStream!; - } - - /// Listens for new notifications - Stream get onNewNotification { - if (_onNewNotificationStream == null) { - throw Exception('Server not created'); - } - return _onNewNotificationStream!; - } - - late final _router = Router() - ..post('/devices', _devicesHandler) - ..post('/notifications', _notificationsHandler) - ..get('/health', (final _) async => Response.ok('')); - - Future _devicesHandler(final Request request) async { - final data = Uri(query: await request.readAsString()).queryParameters; - _onNewDeviceController.add( - PushProxyDevice( - pushToken: data['pushToken']!, - deviceIdentifier: data['deviceIdentifier']!, - deviceIdentifierSignature: data['deviceIdentifierSignature']!, - userPublicKey: data['userPublicKey']!, - ), - ); - return Response.ok(''); - } - - Future _notificationsHandler(final Request request) async { - final data = Uri(query: await request.readAsString()).queryParameters; - for (final notification in data.values) { - final notificationData = json.decode(notification) as Map; - _onNewNotificationController.add( - PushProxyNotification( - deviceIdentifier: notificationData['deviceIdentifier']! as String, - pushTokenHash: notificationData['pushTokenHash']! as String, - subject: notificationData['subject']! as String, - signature: notificationData['signature']! as String, - priority: notificationData['priority']! as String, - type: notificationData['type']! as String, - ), - ); - } - return Response.ok(''); - } - - /// Creates a server listening on the [port] - Future create({ - final bool logging = true, - final int port = 8080, - }) async { - if (_server != null) { - throw Exception('Server already created'); - } - - _onNewDeviceController = StreamController(); - _onNewNotificationController = StreamController(); - _onNewDeviceStream = _onNewDeviceController.stream.asBroadcastStream(); - _onNewNotificationStream = _onNewNotificationController.stream.asBroadcastStream(); - - var handler = Cascade().add(_router.call).handler; - if (logging) { - handler = logRequests().addHandler(handler); - } - final server = await serve( - handler, - InternetAddress.anyIPv4, - port, - ); - server.autoCompress = true; - - _server = server; - } - - /// Closes the server - Future close() async { - if (_server != null) { - await _server!.close(); - _server = null; - await _onNewDeviceController.close(); - await _onNewNotificationController.close(); - } - } -} - -class PushProxyDevice { - PushProxyDevice({ - required this.pushToken, - required this.deviceIdentifier, - required this.deviceIdentifierSignature, - required this.userPublicKey, - }); - - factory PushProxyDevice.fromJson(final Map data) => PushProxyDevice( - pushToken: data['pushToken'] as String, - deviceIdentifier: data['deviceIdentifier'] as String, - deviceIdentifierSignature: data['deviceIdentifierSignature'] as String, - userPublicKey: data['userPublicKey'] as String, - ); - - Map toJson() => { - 'pushToken': pushToken, - 'deviceIdentifier': deviceIdentifier, - 'deviceIdentifierSignature': deviceIdentifierSignature, - 'userPublicKey': userPublicKey, - }; - - final String pushToken; - final String deviceIdentifier; - final String deviceIdentifierSignature; - final String userPublicKey; -} - -class PushProxyNotification { - PushProxyNotification({ - required this.deviceIdentifier, - required this.pushTokenHash, - required this.subject, - required this.signature, - required this.priority, - required this.type, - }); - - final String deviceIdentifier; - final String pushTokenHash; - final String subject; - final String signature; - final String priority; - final String type; - - Map toPushNotificationData() => { - 'subject': subject, - 'signature': signature, - 'priority': priority, - 'type': type, - }; -} diff --git a/packages/nextcloud_push_proxy/mono_pkg.yaml b/packages/nextcloud_push_proxy/mono_pkg.yaml deleted file mode 100644 index 60bc3bfd..00000000 --- a/packages/nextcloud_push_proxy/mono_pkg.yaml +++ /dev/null @@ -1,7 +0,0 @@ -sdk: - - stable - -stages: - - all: - - analyze: --fatal-infos . - - format: --output=none --set-exit-if-changed --line-length 120 . diff --git a/packages/nextcloud_push_proxy/pubspec.yaml b/packages/nextcloud_push_proxy/pubspec.yaml deleted file mode 100644 index fd8700a2..00000000 --- a/packages/nextcloud_push_proxy/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: nextcloud_push_proxy -version: 1.0.0 - -environment: - sdk: '>=2.19.0 <3.0.0' - -dependencies: - crypto: ^3.0.2 - shelf: ^1.3.1 - shelf_router: ^1.1.3 - -dev_dependencies: - nit_picking: - git: - url: https://github.com/stack11/dart_nit_picking - ref: 0b2ee0d diff --git a/skaffold.yaml b/skaffold.yaml deleted file mode 100644 index 1bfbd3f7..00000000 --- a/skaffold.yaml +++ /dev/null @@ -1,23 +0,0 @@ -kind: Config -apiVersion: skaffold/v2beta27 -build: - artifacts: - - image: provokateurin/nextcloud-push-proxy - context: packages/nextcloud_push_proxy - local: - useBuildkit: true - concurrency: 0 - push: true - tagPolicy: - sha256: {} -deploy: - helm: - releases: - - name: nextcloud-neon - chartPath: helm/nextcloud-neon - namespace: nextcloud-neon - valuesFiles: - - helm/nextcloud-neon/values.yaml - artifactOverrides: - imageNextcloudPushProxy: provokateurin/nextcloud-push-proxy - statusCheckDeadlineSeconds: 300 diff --git a/specs/notifications.json b/specs/notifications.json index 52b54be9..08b818fe 100644 --- a/specs/notifications.json +++ b/specs/notifications.json @@ -277,30 +277,7 @@ } } }, - "PushNotification": { - "type": "object", - "required": [ - "accountID", - "priority", - "type", - "subject" - ], - "properties": { - "accountID": { - "type": "string" - }, - "priority": { - "type": "string" - }, - "type": { - "type": "string" - }, - "subject": { - "$ref": "#/components/schemas/PushNotificationDecryptedSubject" - } - } - }, - "PushNotificationDecryptedSubject": { + "NotificationDecryptedSubject": { "type": "object", "properties": { "nid": { diff --git a/tool/run-dev-instance.sh b/tool/run-dev-instance.sh index 6539bf6d..e5222a6e 100755 --- a/tool/run-dev-instance.sh +++ b/tool/run-dev-instance.sh @@ -20,10 +20,6 @@ fi echo "TEST_HOST=$ip TEST_USER=user1 TEST_PASSWORD=user1" > packages/neon/assets/.env -( - cd packages/nextcloud_push_proxy - fvm dart run bin/unified_push.dart ./tmp/devices.json -) & function cleanup() { rm packages/neon/assets/.env