Browse Source

Merge pull request #162 from provokateurin/refactor/push-proxy

Refactor push proxy setup
pull/200/head
Kate 2 years ago committed by GitHub
parent
commit
bbb694eef2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 94
      .github/workflows/dart.yml
  2. 1
      helm/nextcloud-neon/.gitignore
  3. 4
      helm/nextcloud-neon/Chart.yaml
  4. 73
      helm/nextcloud-neon/templates/common-proxies.yaml
  5. 91
      helm/nextcloud-neon/templates/push-proxy.yaml
  6. 8
      packages/neon/integration_test/screenshot_test.dart
  7. 17
      packages/neon/lib/l10n/en.arb
  8. 32
      packages/neon/lib/l10n/localizations.dart
  9. 24
      packages/neon/lib/l10n/localizations_en.dart
  10. 5
      packages/neon/lib/main.dart
  11. 22
      packages/neon/lib/src/app.dart
  12. 8
      packages/neon/lib/src/blocs/apps.dart
  13. 30
      packages/neon/lib/src/blocs/first_launch.dart
  14. 59
      packages/neon/lib/src/blocs/push_notifications.dart
  15. 25
      packages/neon/lib/src/models/push_notification.dart
  16. 21
      packages/neon/lib/src/models/push_notification.g.dart
  17. 20
      packages/neon/lib/src/models/push_notification_with_account.dart
  18. 18
      packages/neon/lib/src/models/push_notification_with_account.g.dart
  19. 3
      packages/neon/lib/src/neon.dart
  20. 827
      packages/neon/lib/src/pages/home.dart
  21. 7
      packages/neon/lib/src/pages/settings.dart
  22. 2
      packages/neon/lib/src/utils/global.dart
  23. 33
      packages/neon/lib/src/utils/global_options.dart
  24. 139
      packages/neon/lib/src/utils/push_utils.dart
  25. 60
      packages/nextcloud/lib/src/helpers.dart
  26. 70
      packages/nextcloud/lib/src/nextcloud.openapi.dart
  27. 29
      packages/nextcloud/lib/src/nextcloud.openapi.g.dart
  28. 25
      packages/nextcloud/lib/src/nextcloud.openapi.json
  29. 4
      packages/nextcloud/pubspec.yaml
  30. 89
      packages/nextcloud/test/notifications.dart
  31. 12
      packages/nextcloud_push_proxy/.gitignore
  32. 17
      packages/nextcloud_push_proxy/Dockerfile
  33. 1
      packages/nextcloud_push_proxy/LICENSE
  34. 1
      packages/nextcloud_push_proxy/analysis_options.yaml
  35. 68
      packages/nextcloud_push_proxy/bin/unified_push.dart
  36. 163
      packages/nextcloud_push_proxy/lib/nextcloud_push_proxy.dart
  37. 7
      packages/nextcloud_push_proxy/mono_pkg.yaml
  38. 16
      packages/nextcloud_push_proxy/pubspec.yaml
  39. 23
      skaffold.yaml
  40. 25
      specs/notifications.json
  41. 4
      tool/run-dev-instance.sh

94
.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:

1
helm/nextcloud-neon/.gitignore vendored

@ -1 +0,0 @@
values.yaml

4
helm/nextcloud-neon/Chart.yaml

@ -1,4 +0,0 @@
apiVersion: "v1"
name: nextcloud-neon
version: 1.0.0
description: nextcloud-neon

73
helm/nextcloud-neon/templates/common-proxies.yaml

@ -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 }}

91
helm/nextcloud-neon/templates/push-proxy.yaml

@ -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

8
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<NextcloudTheme?>();
@ -157,6 +160,9 @@ Future pumpAppPage(
Provider<PushNotificationsBloc>(
create: (final _) => pushNotificationsBloc,
),
Provider<FirstLaunchBloc>(
create: (final _) => firstLaunchBloc,
),
Provider<List<AppImplementation>>(
create: (final _) => allAppImplementations,
),

17
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",

32
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:

24
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';

5
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<PushNotificationsBloc>(
create: (final _) => pushNotificationsBloc,
),
Provider<FirstLaunchBloc>(
create: (final _) => firstLaunchBloc,
),
Provider<List<AppImplementation>>(
create: (final _) => allAppImplementations,
),

22
packages/neon/lib/src/app.dart

@ -70,13 +70,15 @@ class _NeonAppState extends State<NeonApp> 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<NeonApp> with WidgetsBindingObserver, tray.Tra
final allAppImplementations = Provider.of<List<AppImplementation>>(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<NeonApp> with WidgetsBindingObserver, tray.Tra
_accountsBloc
.getAppsBloc(account)
.getAppBloc<NotificationsBloc>(appImplementation)
.deleteNotification(pushNotificationWithAccountID.notification.subject.nid!);
.deleteNotification(pushNotificationWithAccountID.subject.nid!);
}
await _openAppFromExternal(account, appImplementation.id);
};
@ -199,7 +201,7 @@ class _NeonAppState extends State<NeonApp> 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<String, dynamic>,
),
);

8
packages/neon/lib/src/blocs/apps.dart

@ -14,6 +14,8 @@ abstract class AppsBlocStates {
BehaviorSubject<Result<NotificationsApp?>> get notificationsAppImplementation;
BehaviorSubject<String?> 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<Result<NotificationsApp?>> notificationsAppImplementation =
BehaviorSubject<Result<NotificationsApp?>>();
@override
BehaviorSubject openNotifications = BehaviorSubject();
@override
Future refresh() async {
await _requestManager.wrapNextcloud<List<NextcloudApp>, 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');
}

30
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();
}

59
packages/neon/lib/src/blocs/push_notifications.dart

@ -3,7 +3,7 @@ part of '../neon.dart';
abstract class PushNotificationsBlocEvents {}
abstract class PushNotificationsBlocStates {
Stream<NextcloudPushNotification> get notifications;
Stream<PushNotification> 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<NextcloudPushNotification>();
final _notificationsController = StreamController<PushNotification>();
@override
void dispose() {
@ -47,20 +45,14 @@ class PushNotificationsBloc extends Bloc implements PushNotificationsBlocEvents,
}
@override
late Stream<NextcloudPushNotification> notifications = _notificationsController.stream.asBroadcastStream();
late Stream<PushNotification> 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<Account> 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;
}

25
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<String, dynamic> json) => _$PushNotificationFromJson(json);
Map<String, dynamic> toJson() => _$PushNotificationToJson(this);
final String accountID;
final String priority;
final String type;
final NextcloudNotificationsNotificationDecryptedSubject subject;
}

21
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<String, dynamic> json) => PushNotification(
accountID: json['accountID'] as String,
priority: json['priority'] as String,
type: json['type'] as String,
subject: NextcloudNotificationsNotificationDecryptedSubject.fromJson(json['subject'] as Map<String, dynamic>),
);
Map<String, dynamic> _$PushNotificationToJson(PushNotification instance) => <String, dynamic>{
'accountID': instance.accountID,
'priority': instance.priority,
'type': instance.type,
'subject': instance.subject,
};

20
packages/neon/lib/src/models/push_notification_with_account.dart

@ -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<String, dynamic> json) =>
_$PushNotificationWithAccountIDFromJson(json);
Map<String, dynamic> toJson() => _$PushNotificationWithAccountIDToJson(this);
final NextcloudNotificationsPushNotification notification;
final String accountID;
}

18
packages/neon/lib/src/models/push_notification_with_account.g.dart

@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'push_notification_with_account.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PushNotificationWithAccountID _$PushNotificationWithAccountIDFromJson(Map<String, dynamic> json) =>
PushNotificationWithAccountID(
notification: NextcloudNotificationsPushNotification.fromJson(json['notification'] as Map<String, dynamic>),
accountID: json['accountID'] as String,
);
Map<String, dynamic> _$PushNotificationWithAccountIDToJson(PushNotificationWithAccountID instance) => <String, dynamic>{
'notification': instance.notification,
'accountID': instance.accountID,
};

3
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';

827
packages/neon/lib/src/pages/home.dart

@ -21,16 +21,31 @@ class _HomePageState extends State<HomePage> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
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<GlobalOptions>(context, listen: false);
_appsBloc = Provider.of<AccountsBloc>(context, listen: false).getAppsBloc(widget.account);
_capabilitiesBloc = Provider.of<AccountsBloc>(context, listen: false).getCapabilitiesBloc(widget.account);
_accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
_appsBloc = _accountsBloc.getAppsBloc(widget.account);
_capabilitiesBloc = _accountsBloc.getCapabilitiesBloc(widget.account);
_firstLaunchBloc = Provider.of<FirstLaunchBloc>(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<HomePage> {
}
});
_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<HomePage> {
);
}
Future _openNotifications(
final NotificationsApp app,
final List<Account> 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<HomePage> {
}
@override
Widget build(final BuildContext context) {
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
return ResultBuilder<CapabilitiesBloc, Capabilities>(
stream: _capabilitiesBloc.capabilities,
builder: (final context, final capabilities) => ResultBuilder<AppsBloc, List<AppImplementation>>(
stream: _appsBloc.appImplementations,
builder: (final context, final appImplementations) => ResultBuilder<AppsBloc, NotificationsApp?>(
stream: _appsBloc.notificationsAppImplementation,
builder: (final context, final notificationsAppImplementation) => StreamBuilder<String?>(
stream: _appsBloc.activeAppID,
builder: (
final context,
final activeAppIDSnapshot,
) =>
StreamBuilder<List<Account>>(
stream: accountsBloc.accounts,
Widget build(final BuildContext context) => ResultBuilder<CapabilitiesBloc, Capabilities>(
stream: _capabilitiesBloc.capabilities,
builder: (final context, final capabilities) => ResultBuilder<AppsBloc, List<AppImplementation>>(
stream: _appsBloc.appImplementations,
builder: (final context, final appImplementations) => ResultBuilder<AppsBloc, NotificationsApp?>(
stream: _appsBloc.notificationsAppImplementation,
builder: (final context, final notificationsAppImplementation) => StreamBuilder<String?>(
stream: _appsBloc.activeAppID,
builder: (
final context,
final accountsSnapshot,
final activeAppIDSnapshot,
) =>
OptionBuilder<NavigationMode>(
option: _globalOptions.navigationMode,
builder: (final context, final navigationMode) => WillPopScope(
onWillPop: () async {
if (_scaffoldKey.currentState!.isDrawerOpen) {
Navigator.pop(context);
return true;
}
StreamBuilder<List<Account>>(
stream: _accountsBloc.accounts,
builder: (
final context,
final accountsSnapshot,
) =>
OptionBuilder<NavigationMode>(
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<String>(
isExpanded: true,
dropdownColor: Theme.of(context).colorScheme.primary,
iconEnabledColor: Theme.of(context).colorScheme.onBackground,
value: widget.account.id,
items: accounts
.map<DropdownMenuItem<String>>(
(final account) => DropdownMenuItem<String>(
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<int>(
stream: appImplementation.getUnreadCounter(_appsBloc) ??
BehaviorSubject<int>.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<String>(
isExpanded: true,
dropdownColor: Theme.of(context).colorScheme.primary,
iconEnabledColor:
Theme.of(context).colorScheme.onBackground,
value: widget.account.id,
items: accounts
.map<DropdownMenuItem<String>>(
(final account) => DropdownMenuItem<String>(
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<int>(
stream: appImplementation.getUnreadCounter(_appsBloc) ??
BehaviorSubject<int>.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<int>(
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<int>(
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();
},
),
),
),
),
),
),
),
),
);
}
);
}

7
packages/neon/lib/src/pages/settings.dart

@ -119,13 +119,6 @@ class _SettingsPageState extends State<SettingsPage> {
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(

2
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;
}

33
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<ThemeMode>(
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,
);

139
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<String, dynamic>,
),
);
@ -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<String, dynamic>;
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<String, dynamic>;
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);
}

60
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<String, dynamic>,
);
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),

70
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<String, dynamic> json) =>
_$NextcloudNotificationsPushNotificationDecryptedSubjectFromJson(json);
factory NextcloudNotificationsNotificationDecryptedSubject.fromJson(Map<String, dynamic> json) =>
_$NextcloudNotificationsNotificationDecryptedSubjectFromJson(json);
// coverage:ignore-end
// coverage:ignore-start
factory NextcloudNotificationsPushNotificationDecryptedSubject.fromJsonString(String data) =>
NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(json.decode(data) as Map<String, dynamic>);
factory NextcloudNotificationsNotificationDecryptedSubject.fromJsonString(String data) =>
NextcloudNotificationsNotificationDecryptedSubject.fromJson(json.decode(data) as Map<String, dynamic>);
// coverage:ignore-end
final int? nid;
@ -5215,43 +5215,9 @@ class NextcloudNotificationsPushNotificationDecryptedSubject {
final bool? deleteAll;
// coverage:ignore-start
Map<String, dynamic> toJson() => _$NextcloudNotificationsPushNotificationDecryptedSubjectToJson(this);
Map<String, dynamic> 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<String, dynamic> json) =>
_$NextcloudNotificationsPushNotificationFromJson(json);
// coverage:ignore-end
// coverage:ignore-start
factory NextcloudNotificationsPushNotification.fromJsonString(String data) =>
NextcloudNotificationsPushNotification.fromJson(json.decode(data) as Map<String, dynamic>);
// coverage:ignore-end
final String accountID;
final String priority;
final String type;
final NextcloudNotificationsPushNotificationDecryptedSubject subject;
// coverage:ignore-start
Map<String, dynamic> 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 = <Type, dynamic Function(dynamic)>{
NextcloudUserStatusPredefinedStatus.fromJson(data as Map<String, dynamic>),
List<NextcloudUserStatusPredefinedStatus>: (final data) =>
(data as List).map((final e) => NextcloudUserStatusPredefinedStatus.fromJson(e as Map<String, dynamic>)).toList(),
NextcloudNotificationsPushNotification: (final data) =>
NextcloudNotificationsPushNotification.fromJson(data as Map<String, dynamic>),
List<NextcloudNotificationsPushNotification>: (final data) => (data as List)
.map((final e) => NextcloudNotificationsPushNotification.fromJson(e as Map<String, dynamic>))
.toList(),
NextcloudNotificationsPushNotificationDecryptedSubject: (final data) =>
NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(data as Map<String, dynamic>),
List<NextcloudNotificationsPushNotificationDecryptedSubject>: (final data) => (data as List)
.map((final e) => NextcloudNotificationsPushNotificationDecryptedSubject.fromJson(e as Map<String, dynamic>))
NextcloudNotificationsNotificationDecryptedSubject: (final data) =>
NextcloudNotificationsNotificationDecryptedSubject.fromJson(data as Map<String, dynamic>),
List<NextcloudNotificationsNotificationDecryptedSubject>: (final data) => (data as List)
.map((final e) => NextcloudNotificationsNotificationDecryptedSubject.fromJson(e as Map<String, dynamic>))
.toList(),
};
@ -6036,11 +5997,8 @@ final _serializers = <Type, dynamic Function(dynamic)>{
List<NextcloudUserStatusPredefinedStatuses_Ocs>: (final data) => data.map((final e) => e.toJson()).toList(),
NextcloudUserStatusPredefinedStatus: (final data) => data.toJson(),
List<NextcloudUserStatusPredefinedStatus>: (final data) => data.map((final e) => e.toJson()).toList(),
NextcloudNotificationsPushNotification: (final data) => data.toJson(),
List<NextcloudNotificationsPushNotification>: (final data) => data.map((final e) => e.toJson()).toList(),
NextcloudNotificationsPushNotificationDecryptedSubject: (final data) => data.toJson(),
List<NextcloudNotificationsPushNotificationDecryptedSubject>: (final data) =>
data.map((final e) => e.toJson()).toList(),
NextcloudNotificationsNotificationDecryptedSubject: (final data) => data.toJson(),
List<NextcloudNotificationsNotificationDecryptedSubject>: (final data) => data.map((final e) => e.toJson()).toList(),
};
T deserializeNextcloud<T>(final dynamic data) => _deserializers[T]!(data) as T;

29
packages/nextcloud/lib/src/nextcloud.openapi.g.dart

@ -2193,13 +2193,13 @@ Map<String, dynamic> _$NextcloudUserStatusPredefinedStatusesToJson(NextcloudUser
'ocs': instance.ocs.toJson(),
};
NextcloudNotificationsPushNotificationDecryptedSubject _$NextcloudNotificationsPushNotificationDecryptedSubjectFromJson(
NextcloudNotificationsNotificationDecryptedSubject _$NextcloudNotificationsNotificationDecryptedSubjectFromJson(
Map<String, dynamic> 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<String, dynamic> _$NextcloudNotificationsPushNotificationDecryptedSubjectToJson(
NextcloudNotificationsPushNotificationDecryptedSubject instance) =>
Map<String, dynamic> _$NextcloudNotificationsNotificationDecryptedSubjectToJson(
NextcloudNotificationsNotificationDecryptedSubject instance) =>
<String, dynamic>{
'nid': instance.nid,
'app': instance.app,
@ -2221,24 +2221,3 @@ Map<String, dynamic> _$NextcloudNotificationsPushNotificationDecryptedSubjectToJ
'delete': instance.delete,
'delete-all': instance.deleteAll,
};
NextcloudNotificationsPushNotification _$NextcloudNotificationsPushNotificationFromJson(Map<String, dynamic> 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<String, dynamic>),
);
}
Map<String, dynamic> _$NextcloudNotificationsPushNotificationToJson(NextcloudNotificationsPushNotification instance) =>
<String, dynamic>{
'accountID': instance.accountID,
'priority': instance.priority,
'type': instance.type,
'subject': instance.subject.toJson(),
};

25
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": {

4
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:

89
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/',
))

12
packages/nextcloud_push_proxy/.gitignore vendored

@ -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/

17
packages/nextcloud_push_proxy/Dockerfile

@ -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"]

1
packages/nextcloud_push_proxy/LICENSE

@ -1 +0,0 @@
../../LICENSE

1
packages/nextcloud_push_proxy/analysis_options.yaml

@ -1 +0,0 @@
include: package:nit_picking/dart.yaml

68
packages/nextcloud_push_proxy/bin/unified_push.dart

@ -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<String> args) async {
if (args.length != 1) {
throw Exception('Provide the file where to store devices');
}
final devices = <PushProxyDevice>[];
final devicesFile = File(args[0]);
if (devicesFile.existsSync()) {
devices.addAll(
(json.decode(devicesFile.readAsStringSync()) as List)
.map((final d) => PushProxyDevice.fromJson(d as Map<String, dynamic>)),
);
}
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);
}
}

163
packages/nextcloud_push_proxy/lib/nextcloud_push_proxy.dart

@ -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<PushProxyDevice> _onNewDeviceController;
late StreamController<PushProxyNotification> _onNewNotificationController;
Stream<PushProxyDevice>? _onNewDeviceStream;
Stream<PushProxyNotification>? _onNewNotificationStream;
/// Listens for new devices
Stream<PushProxyDevice> get onNewDevice {
if (_onNewDeviceStream == null) {
throw Exception('Server not created');
}
return _onNewDeviceStream!;
}
/// Listens for new notifications
Stream<PushProxyNotification> 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<Response> _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<Response> _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<String, dynamic>;
_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<PushProxyDevice>();
_onNewNotificationController = StreamController<PushProxyNotification>();
_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<String, dynamic> data) => PushProxyDevice(
pushToken: data['pushToken'] as String,
deviceIdentifier: data['deviceIdentifier'] as String,
deviceIdentifierSignature: data['deviceIdentifierSignature'] as String,
userPublicKey: data['userPublicKey'] as String,
);
Map<String, dynamic> 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<String, String> toPushNotificationData() => {
'subject': subject,
'signature': signature,
'priority': priority,
'type': type,
};
}

7
packages/nextcloud_push_proxy/mono_pkg.yaml

@ -1,7 +0,0 @@
sdk:
- stable
stages:
- all:
- analyze: --fatal-infos .
- format: --output=none --set-exit-if-changed --line-length 120 .

16
packages/nextcloud_push_proxy/pubspec.yaml

@ -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

23
skaffold.yaml

@ -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

25
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": {

4
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

Loading…
Cancel
Save