Compare commits

...

31 Commits

Author SHA1 Message Date
7546c2a1a4
Fix test pipeline
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m17s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 2m38s
Build Docker and Deploy / Deploy to Server (push) Successful in 9s
2024-09-20 21:23:26 +02:00
d21d775764
Add ListSenderNames api route and use params.Add(..) in Filter classes
Some checks failed
Build Docker and Deploy / Run Unit-Tests (push) Failing after 10s
Build Docker and Deploy / Build Docker Container (push) Successful in 1m14s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2024-09-20 20:37:55 +02:00
352f1ca0d1
Fully switch away from mattn sqlite to glebarez sqlite 2024-09-20 17:21:32 +02:00
584a9e983f
Add tests [TestListSenderNames] [TestListUserSenderNames] 2024-09-20 16:33:45 +02:00
5dd94eca38
Add test stage to pipeline 2024-09-20 15:39:15 +02:00
d8c06e3de2
Fix test [TestListMessagesFilterChannel] 2024-09-20 15:36:16 +02:00
3adeadf6fb
Work on implementing search filter in app [WIP] 2024-09-19 19:46:46 +02:00
9d35916280
Fix missing field in clients struct and non-partial fcmtoken index (also streamline db migrations)
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m52s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s
2024-09-17 22:26:45 +02:00
4c7632a144
Set delivery to FAILURE if [client|user|message|channel] no longer exists
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m52s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2024-09-17 20:49:10 +02:00
e329e13a02
Auto-delete clients when FB returns UNREGISTERED
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m53s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s
2024-09-16 20:11:28 +02:00
7ddaf5d9aa
Migrate deliveries.next_delivery from type:string to type:int (SCNTime)
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m46s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2024-09-16 18:26:28 +02:00
5da4c3d3b9
Fix dbConverter error when unmarshalling (failed) deliveries
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m48s
Build Docker and Deploy / Deploy to Server (push) Successful in 5s
2024-09-16 17:55:13 +02:00
fb1560a1f5
go generate
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 2m19s
Build Docker and Deploy / Deploy to Server (push) Successful in 13s
2024-09-16 16:46:26 +02:00
61d62f736c
Merge branch 'flutter_app' 2024-09-16 16:23:46 +02:00
77362f1651
Merge branch 'refactor_server' 2024-09-16 16:23:15 +02:00
e93d125431
Properly handle click actions on notifications 2024-07-13 01:05:32 +02:00
74a935f6f1
Fix scn-requests box not being open in _onBackgroundMessage 2024-07-13 00:17:22 +02:00
be7035978b
channel_message_list 2024-07-13 00:11:13 +02:00
778451fa4c
channel list fixes 2024-07-12 23:08:56 +02:00
89d1e0f641
edit displayName/descriptionName of channel 2024-06-26 14:54:34 +02:00
1f9b65652d
get channel->lastMessage from cache before hot-loading 2024-06-25 20:54:03 +02:00
2b23404461
channel_view page 2024-06-25 20:49:40 +02:00
e2dbe8866d
Channel List/view WIP 2024-06-25 12:00:34 +02:00
7dad61dbbb
Fix re-layout in message_view after data is loaded 2024-06-23 13:31:10 +02:00
9542405512
fix linebreaks in message.title in channel_list_item 2024-06-18 17:36:41 +02:00
59d28d3c49
auto-refresh message-list on FB message receive 2024-06-17 23:23:35 +02:00
600f3365f6
Disabled didPopNext() refresh of message_list 2024-06-17 22:54:45 +02:00
5b8a1e86e0
Save user+client in Prefs and only background-fetch them on startup 2024-06-17 22:53:03 +02:00
c8bc7665f7
Fix background messages in release-build 2024-06-17 22:26:48 +02:00
0bbe5fc7fa
Working on message search+filter 2024-06-16 01:46:27 +02:00
e9ea573e33
Notifications (android via local) work 2024-06-15 21:29:51 +02:00
124 changed files with 5560 additions and 1040 deletions

View File

@ -13,7 +13,7 @@ on:
jobs: jobs:
build_job: build_server:
name: Build Docker Container name: Build Docker Container
runs-on: bfb-cicd-latest runs-on: bfb-cicd-latest
steps: steps:
@ -24,9 +24,49 @@ jobs:
- run: cd "${{ gitea.workspace }}/scnserver" && make docker - run: cd "${{ gitea.workspace }}/scnserver" && make docker
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker - run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
deploy_job: test_server:
name: Run Unit-Tests
runs-on: bfb-cicd-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Get Commiter Info
id: commiter_info
run: |
echo "NAME=$( git log -n 1 --pretty=format:%an )" >> $GITHUB_OUTPUT
echo "MAIL=$( git log -n 1 --pretty=format:%ae )" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: '${{ gitea.workspace }}/scnserver/go.mod'
cache: false
- name: Print Go Version
run: go version
- name: Run tests
run: cd "${{ gitea.workspace }}/scnserver" && make dgi && make swagger && SCN_TEST_LOGLEVEL=WARN make test
- name: Send failure mail
if: failure()
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.fastmail.com
server_port: 465
secure: true
username: ${{secrets.MAIL_USERNAME}}
password: ${{secrets.MAIL_PASSWORD}}
subject: Pipeline on '${{ gitea.repository }}' failed
to: ${{ steps.commiter_info.outputs.MAIL }}
from: Gitea Actions <gitea_actions@blackforestbytes.de>
body: "Go to https://gogs.blackforestbytes.com/${{ gitea.repository }}/actions"
deploy_server:
name: Deploy to Server name: Deploy to Server
needs: [build_job] needs: [build_server, test_server]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Execute deploy on remote (via ssh) - name: Execute deploy on remote (via ssh)

View File

@ -4,6 +4,12 @@ run:
flutter pub run build_runner build flutter pub run build_runner build
flutter run flutter run
run-android:
ping -c1 10.10.10.177
adb connect 10.10.10.177:5555
flutter pub run build_runner build
flutter run -d 10.10.10.177:5555
test: test:
dart analyze dart analyze

View File

@ -37,6 +37,7 @@ android {
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
@ -55,6 +56,7 @@ android {
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
multiDexEnabled true
} }
signingConfigs { signingConfigs {
@ -77,4 +79,9 @@ flutter {
source '../..' source '../..'
} }
dependencies {} dependencies {
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
}

27
flutter/android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,27 @@
## Gson rules
# Gson uses generic type information stored in a class file when working with fields. Proguard
# removes such information by default, so configure it to keep all of it.
-keepattributes Signature
# For using GSON @Expose annotation
-keepattributes *Annotation*
# Gson specific classes
-dontwarn sun.misc.**
#-keep class com.google.gson.stream.** { *; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Prevent R8 from leaving Data object members always null
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken

View File

@ -31,6 +31,9 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@drawable/*" />

View File

@ -6,6 +6,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }

View File

@ -1,13 +1,27 @@
import UIKit import UIKit
import Flutter import Flutter
import flutter_local_notifications
@UIApplicationMain @UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// This is required to make any communication available in the action isolate.
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
GeneratedPluginRegistrant.register(with: registry)
}
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }

View File

@ -7,11 +7,12 @@ import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/state/token_source.dart'; import 'package:simplecloudnotifier/state/token_source.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
@ -26,6 +27,27 @@ enum ChannelSelector {
final String apiKey; final String apiKey;
} }
class MessageFilter {
List<String>? channelIDs;
List<String>? searchFilter;
List<String>? senderNames;
List<String>? usedKeys;
List<int>? priority;
DateTime? timeBefore;
DateTime? timeAfter;
bool? hasSenderName;
MessageFilter({
this.channelIDs,
this.searchFilter,
this.senderNames,
this.usedKeys,
this.priority,
this.timeBefore,
this.timeAfter,
});
}
class APIClient { class APIClient {
static const String _base = 'https://simplecloudnotifier.de/api/v2'; static const String _base = 'https://simplecloudnotifier.de/api/v2';
@ -33,7 +55,7 @@ class APIClient {
required String name, required String name,
required String method, required String method,
required String relURL, required String relURL,
Map<String, String>? query, Map<String, Iterable<String>>? query,
required T Function(Map<String, dynamic> json)? fn, required T Function(Map<String, dynamic> json)? fn,
dynamic jsonBody, dynamic jsonBody,
String? authToken, String? authToken,
@ -45,7 +67,7 @@ class APIClient {
final req = http.Request(method, uri); final req = http.Request(method, uri);
print('[REQUEST|RUN] [${method}] ${name}'); print('[REQUEST|RUN] [${method}] ${name} | ${uri.toString()}');
if (jsonBody != null) { if (jsonBody != null) {
req.body = jsonEncode(jsonBody); req.body = jsonEncode(jsonBody);
@ -185,7 +207,9 @@ class APIClient {
name: 'getChannelList', name: 'getChannelList',
method: 'GET', method: 'GET',
relURL: 'users/${auth.getUserID()}/channels', relURL: 'users/${auth.getUserID()}/channels',
query: {'selector': sel.apiKey}, query: {
'selector': [sel.apiKey]
},
fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>), fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>),
authToken: auth.getToken(), authToken: auth.getToken(),
); );
@ -211,28 +235,48 @@ class APIClient {
); );
} }
static Future<(String, List<Message>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async { static Future<ChannelWithSubscription> updateChannel(AppAuth auth, String cid, {String? displayName, String? descriptionName}) async {
return await _request(
name: 'updateChannel',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/channels/${cid}',
jsonBody: {
if (displayName != null) 'display_name': displayName,
if (descriptionName != null) 'description_name': descriptionName,
},
fn: ChannelWithSubscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async {
return await _request( return await _request(
name: 'getMessageList', name: 'getMessageList',
method: 'GET', method: 'GET',
relURL: 'messages', relURL: 'messages',
query: { query: {
'next_page_token': pageToken, 'next_page_token': [pageToken],
if (pageSize != null) 'page_size': pageSize.toString(), if (pageSize != null) 'page_size': [pageSize.toString()],
if (channelIDs != null) 'channel_id': channelIDs.join(","), if (filter?.searchFilter != null) 'search': filter!.searchFilter!,
if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!,
if (filter?.senderNames != null) 'sender': filter!.senderNames!,
if (filter?.hasSenderName != null) 'has_sender': [filter!.hasSenderName!.toString()],
if (filter?.timeBefore != null) 'before': [filter!.timeBefore!.toIso8601String()],
if (filter?.timeAfter != null) 'after': [filter!.timeAfter!.toIso8601String()],
if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).toList(),
if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!,
}, },
fn: (json) => Message.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
authToken: auth.getToken(), authToken: auth.getToken(),
); );
} }
static Future<Message> getMessage(TokenSource auth, String msgid) async { static Future<SCNMessage> getMessage(TokenSource auth, String msgid) async {
return await _request( return await _request(
name: 'getMessage', name: 'getMessage',
method: 'GET', method: 'GET',
relURL: 'messages/$msgid', relURL: 'messages/$msgid',
query: {}, fn: SCNMessage.fromJson,
fn: Message.fromJson,
authToken: auth.getToken(), authToken: auth.getToken(),
); );
} }
@ -247,6 +291,16 @@ class APIClient {
); );
} }
static Future<List<Subscription>> getChannelSubscriptions(TokenSource auth, String cid) async {
return await _request(
name: 'getChannelSubscriptions',
method: 'GET',
relURL: 'users/${auth.getUserID()}/channels/${cid}/subscriptions',
fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List<dynamic>),
authToken: auth.getToken(),
);
}
static Future<List<Client>> getClientList(TokenSource auth) async { static Future<List<Client>> getClientList(TokenSource auth) async {
return await _request( return await _request(
name: 'getClientList', name: 'getClientList',
@ -314,4 +368,8 @@ class APIClient {
authToken: token, authToken: token,
); );
} }
static Future<List<String>> getSenderNameList(AppAuth userAcc) {
return Future.value(['TODO']); //TODO
}
} }

View File

@ -1,81 +0,0 @@
import 'package:flutter/material.dart';
// https://stackoverflow.com/questions/46480221/flutter-floating-action-button-with-speed-dail
class FabWithIcons extends StatefulWidget {
FabWithIcons({super.key, required this.icons, required this.onIconTapped});
final List<IconData> icons;
final ValueChanged<int> onIconTapped;
@override
State createState() => FabWithIconsState();
}
class FabWithIconsState extends State<FabWithIcons> with TickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(widget.icons.length, (int index) {
return _buildChild(index);
}).toList()
..add(
_buildFab(),
),
);
}
Widget _buildChild(int index) {
Color backgroundColor = Theme.of(context).cardColor;
Color foregroundColor = Theme.of(context).secondaryHeaderColor;
return Container(
height: 70.0,
width: 56.0,
alignment: FractionalOffset.topCenter,
child: ScaleTransition(
scale: CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 1.0 - index / widget.icons.length / 2.0, curve: Curves.easeOut),
),
child: FloatingActionButton(
backgroundColor: backgroundColor,
mini: true,
child: Icon(widget.icons[index], color: foregroundColor),
onPressed: () => _onTapped(index),
),
),
);
}
Widget _buildFab() {
return FloatingActionButton(
onPressed: () {
if (_controller.isDismissed) {
_controller.forward();
} else {
_controller.reverse();
}
},
tooltip: 'Increment',
elevation: 2.0,
child: const Icon(Icons.add),
);
}
void _onTapped(int index) {
_controller.reverse();
widget.onIconTapped(index);
}
}

View File

@ -3,17 +3,20 @@ import 'package:flutter/material.dart';
class HidableFAB extends StatelessWidget { class HidableFAB extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
final IconData icon; final IconData icon;
final Object heroTag;
const HidableFAB({ const HidableFAB({
super.key, super.key,
this.onPressed, this.onPressed,
required this.icon, required this.icon,
required this.heroTag,
}); });
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Visibility( return Visibility(
visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown
child: FloatingActionButton( child: FloatingActionButton(
heroTag: this.heroTag,
onPressed: onPressed, onPressed: onPressed,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))),
elevation: 2.0, elevation: 2.0,

View File

@ -1,18 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { class SCNAppBar extends StatefulWidget implements PreferredSizeWidget {
const SCNAppBar({ SCNAppBar({
Key? key, Key? key,
required this.title, required this.title,
required this.showThemeSwitch, required this.showThemeSwitch,
required this.showDebug,
required this.showSearch, required this.showSearch,
required this.showShare, required this.showShare,
this.onShare = null, this.onShare = null,
@ -20,16 +23,33 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
final String? title; final String? title;
final bool showThemeSwitch; final bool showThemeSwitch;
final bool showDebug;
final bool showSearch; final bool showSearch;
final bool showShare; final bool showShare;
final void Function()? onShare; final void Function()? onShare;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
State<SCNAppBar> createState() => _SCNAppBarState();
}
class _SCNAppBarState extends State<SCNAppBar> {
final TextEditingController _ctrlSearchField = TextEditingController();
@override
void dispose() {
_ctrlSearchField.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
var actions = <Widget>[]; var actions = <Widget>[];
if (showDebug) { if (cfg.showDebugButton) {
actions.add(IconButton( actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug', tooltip: 'Debug',
@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
)); ));
} }
if (showThemeSwitch) { if (widget.showThemeSwitch) {
actions.add(Consumer<AppTheme>( actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton( builder: (context, appTheme, child) => IconButton(
icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon),
@ -48,54 +68,118 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
)); ));
} else { } else {
actions.add(Visibility( actions.add(_buildSpacer());
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
} }
if (showSearch) { if (widget.showSearch) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidFilter),
tooltip: 'Filter',
onPressed: () => _showFilterDialog(context),
));
actions.add(IconButton( actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
tooltip: 'Search', tooltip: 'Search',
onPressed: () {/*TODO*/}, onPressed: () => AppBarState().setShowSearchField(true),
)); ));
} else if (showShare) { } else if (widget.showShare) {
actions.add(_buildSpacer());
actions.add(IconButton( actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidShareNodes), icon: const Icon(FontAwesomeIcons.solidShareNodes),
tooltip: 'Share', tooltip: 'Share',
onPressed: onShare ?? () {}, onPressed: widget.onShare ?? () {},
)); ));
} else { } else {
actions.add(Visibility( actions.add(_buildSpacer());
visible: false, actions.add(_buildSpacer());
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
} }
return AppBar( return Consumer<AppBarState>(builder: (context, value, child) {
title: Text(title ?? 'Simple Cloud Notifier 2.0'), if (value.showSearchField) {
actions: actions, return AppBar(
backgroundColor: Theme.of(context).secondaryHeaderColor, leading: IconButton(
bottom: PreferredSize( icon: const Icon(FontAwesomeIcons.solidArrowLeft),
preferredSize: Size(double.infinity, 1.0), onPressed: () {
child: AppBarProgressIndicator(), value.setShowSearchField(false);
},
),
title: _buildSearchTextField(context),
actions: [
IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
onPressed: () {
value.setShowSearchField(false);
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear();
},
),
],
backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
),
);
} else {
return AppBar(
title: Text(widget.title ?? 'SCN'),
actions: actions,
backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
),
);
}
});
}
Visibility _buildSpacer() {
return Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/* NO-OP */},
), ),
); );
} }
@override Widget _buildSearchTextField(BuildContext context) {
Size get preferredSize => const Size.fromHeight(kToolbarHeight); return TextField(
controller: _ctrlSearchField,
autofocus: true,
style: TextStyle(fontSize: 20),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search',
),
onSubmitted: (value) {
AppBarState().setShowSearchField(false);
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear();
},
);
}
void _showFilterDialog(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)),
alignment: Alignment.topCenter,
insetPadding: EdgeInsets.fromLTRB(0, this.widget.preferredSize.height, 0, 0),
backgroundColor: Colors.transparent,
child: AppBarFilterDialog(),
);
},
);
}
} }

View File

@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_channel.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_priority.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class AppBarFilterDialog extends StatefulWidget {
@override
_AppBarFilterDialogState createState() => _AppBarFilterDialogState();
}
class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
double _height = 0;
double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4;
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
setState(() {
_height = _targetHeight;
});
});
}
@override
Widget build(BuildContext context) {
double vpWidth = MediaQuery.sizeOf(context).width;
return Container(
margin: const EdgeInsets.all(0),
width: vpWidth,
color: Colors.transparent,
child: Column(
children: [
Container(
color: Theme.of(context).secondaryHeaderColor,
child: AnimatedContainer(
duration: Duration(milliseconds: 350),
curve: Curves.easeInCubic,
height: _height,
child: ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
maxWidth: vpWidth,
minWidth: vpWidth,
minHeight: 0,
maxHeight: _targetHeight,
child: Column(
children: [
SizedBox(height: 4),
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search', _showSearch),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.snake, 'Channel', _showChannelModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.signature, 'Sender', _showSenderModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.timer, 'Time', _showTimeModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority', _showPriorityModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key', _showKeytokenModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlassPlus, 'Search (Plain)', _showPlainSearchModal),
SizedBox(height: 4),
],
),
),
),
),
),
Expanded(child: GestureDetector(child: Container(width: vpWidth, color: Color(0x88000000)), onTap: () => Navi.popDialog(context))),
],
),
);
}
Widget _buildFilterItem(BuildContext context, IconData icon, String label, void Function(BuildContext context) action) {
return ListTile(
visualDensity: VisualDensity.compact,
title: Text(label),
leading: Icon(icon),
onTap: () {
Navi.popDialog(context);
action(context);
},
);
}
void _showSearch(BuildContext context) {
AppBarState().setShowSearchField(true);
}
void _showPriorityModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalPriority());
}
void _showChannelModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalChannel());
}
void _showSenderModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSendername());
}
void _showKeytokenModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalKeytoken());
}
void _showTimeModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalTime());
}
void _showPlainSearchModal(BuildContext context) {
//TODO showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSearchPlain());
}
}

View File

@ -1,21 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget { class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget {
AppBarProgressIndicator({required this.show});
final bool show;
@override @override
Size get preferredSize => Size(double.infinity, 1.0); Size get preferredSize => Size(double.infinity, 1.0);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AppBarState>( if (show) {
builder: (context, value, child) { return LinearProgressIndicator(value: null);
if (value.loadingIndeterminate) { } else {
return LinearProgressIndicator(value: null); return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
} else { }
return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
}
},
);
} }
} }

View File

@ -7,7 +7,6 @@ class SCNScaffold extends StatelessWidget {
required this.child, required this.child,
this.title, this.title,
this.showThemeSwitch = true, this.showThemeSwitch = true,
this.showDebug = true,
this.showSearch = true, this.showSearch = true,
this.showShare = false, this.showShare = false,
this.onShare = null, this.onShare = null,
@ -16,7 +15,6 @@ class SCNScaffold extends StatelessWidget {
final Widget child; final Widget child;
final String? title; final String? title;
final bool showThemeSwitch; final bool showThemeSwitch;
final bool showDebug;
final bool showSearch; final bool showSearch;
final bool showShare; final bool showShare;
final void Function()? onShare; final void Function()? onShare;
@ -27,7 +25,6 @@ class SCNScaffold extends StatelessWidget {
appBar: SCNAppBar( appBar: SCNAppBar(
title: title, title: title,
showThemeSwitch: showThemeSwitch, showThemeSwitch: showThemeSwitch,
showDebug: showDebug,
showSearch: showSearch, showSearch: showSearch,
showShare: showShare, showShare: showShare,
onShare: onShare ?? () {}, onShare: onShare ?? () {},

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalChannel extends StatefulWidget {
@override
_FilterModalChannelState createState() => _FilterModalChannelState();
}
class _FilterModalChannelState extends State<FilterModalChannel> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<Channel>>? _futureChannels;
@override
void initState() {
super.initState();
_futureChannels = null;
_futureChannels = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
return channels.where((p) => p.subscription?.confirmed ?? false).map((e) => e.channel).toList(); // return only subscribed channels
}());
}
void toggleEntry(String channelID) {
setState(() {
if (_selectedEntries.contains(channelID)) {
_selectedEntries.remove(channelID);
} else {
_selectedEntries.add(channelID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Channels'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureChannels == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureChannels!.future,
builder: ((context, snapshot) {
if (_futureChannels?.value != null) {
return _buildList(context, _futureChannels!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???',
value: e,
type: MessageFilterChipletType.channel,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.channel], chiplets);
}
Widget _buildList(BuildContext context, List<Channel> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final channel = list[index];
return ListTile(
title: Text(channel.displayName),
leading: Icon(_selectedEntries.contains(channel.channelID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(channel.channelID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalKeytoken extends StatefulWidget {
@override
_FilterModalKeytokenState createState() => _FilterModalKeytokenState();
}
class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<KeyToken>>? _futureKeyTokens;
@override
void initState() {
super.initState();
_futureKeyTokens = null;
_futureKeyTokens = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final toks = await APIClient.getKeyTokenList(userAcc);
return toks;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureKeyTokens == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureKeyTokens!.future,
builder: ((context, snapshot) {
if (_futureKeyTokens?.value != null) {
return _buildList(context, _futureKeyTokens!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???',
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<KeyToken> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender.name),
leading: Icon(_selectedEntries.contains(sender.keytokenID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender.keytokenID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
class FilterModalPriority extends StatefulWidget {
@override
_FilterModalPriorityState createState() => _FilterModalPriorityState();
}
class _FilterModalPriorityState extends State<FilterModalPriority> {
Set<int> _selectedEntries = {};
Map<int, (String, String)> _texts = {
0: ('Low (0)', 'Low'),
1: ('Normal (1)', 'Normal'),
2: ('High (2)', 'High'),
};
void toggleEntry(int entry) {
setState(() {
if (_selectedEntries.contains(entry)) {
_selectedEntries.remove(entry);
} else {
_selectedEntries.add(entry);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Priority'),
content: Container(
width: 0,
height: 200,
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
return ListTile(
title: Text(_texts[index]?.$1 ?? '???'),
leading: Icon(_selectedEntries.contains(index) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(index),
);
},
itemCount: 3,
),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.priority], chiplets);
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalSendername extends StatefulWidget {
@override
_FilterModalSendernameState createState() => _FilterModalSendernameState();
}
class _FilterModalSendernameState extends State<FilterModalSendername> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<String>>? _futureSenders;
@override
void initState() {
super.initState();
_futureSenders = null;
_futureSenders = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final senders = await APIClient.getSenderNameList(userAcc);
return senders;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureSenders == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureSenders!.future,
builder: ((context, snapshot) {
if (_futureSenders?.value != null) {
return _buildList(context, _futureSenders!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: e,
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<String> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender),
leading: Icon(_selectedEntries.contains(sender) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalTime extends StatefulWidget {
@override
_FilterModalTimeState createState() => _FilterModalTimeState();
}
class _FilterModalTimeState extends State<FilterModalTime> {
DateTime? _tsBefore = null;
DateTime? _tsAfter = null;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Timerange'),
content: Container(
width: 9000,
height: 9000,
child: Placeholder(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
//TODO
}
}

View File

@ -2,14 +2,20 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/nav_layout.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/fb_message.dart'; import 'package:simplecloudnotifier/state/fb_message.dart';
@ -17,7 +23,9 @@ import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/notifier.dart';
import 'package:toastification/toastification.dart'; import 'package:toastification/toastification.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
@ -39,20 +47,10 @@ void main() async {
Hive.registerAdapter(SCNRequestAdapter()); Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter()); Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter()); Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(MessageAdapter()); Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter()); Hive.registerAdapter(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter()); Hive.registerAdapter(FBMessageAdapter());
print('[INIT] Load Hive<scn-requests>...');
try {
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-requests');
await Hive.openBox<SCNRequest>('scn-requests');
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-logs>...'); print('[INIT] Load Hive<scn-logs>...');
try { try {
@ -63,13 +61,23 @@ void main() async {
ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace);
} }
print('[INIT] Load Hive<scn-requests>...');
try {
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-requests');
await Hive.openBox<SCNRequest>('scn-requests');
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-message-cache>...'); print('[INIT] Load Hive<scn-message-cache>...');
try { try {
await Hive.openBox<Message>('scn-message-cache'); await Hive.openBox<SCNMessage>('scn-message-cache');
} catch (exc, trace) { } catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-message-cache'); Hive.deleteBoxFromDisk('scn-message-cache');
await Hive.openBox<Message>('scn-message-cache'); await Hive.openBox<SCNMessage>('scn-message-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace); ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace);
} }
@ -98,20 +106,19 @@ void main() async {
final appAuth = AppAuth(); // ensure UserAccount is loaded final appAuth = AppAuth(); // ensure UserAccount is loaded
if (appAuth.isAuth()) { if (appAuth.isAuth()) {
try { // load user+client in background
print('[INIT] Load User...'); () async {
await appAuth.loadUser(); try {
//TODO fallback to cached user (perhaps event use cached client (if exists) directly and only update/load in background) await appAuth.loadUser();
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
} }
try { try {
print('[INIT] Load Client...'); await appAuth.loadClient();
await appAuth.loadClient(); } catch (exc, trace) {
//TODO fallback to cached client (perhaps event use cached client (if exists) directly and only update/load in background) ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
} catch (exc, trace) { }
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace); }();
}
} }
if (!Platform.isLinux) { if (!Platform.isLinux) {
@ -147,6 +154,46 @@ void main() async {
print('[INIT] Skip Firebase init (Platform == Linux)...'); print('[INIT] Skip Firebase init (Platform == Linux)...');
} }
print('[INIT] Load Notifications...');
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final flutterLocalNotificationsPluginImpl = flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (flutterLocalNotificationsPluginImpl == null) {
ApplicationLog.error('Failed to get AndroidFlutterLocalNotificationsPlugin', trace: StackTrace.current);
} else {
flutterLocalNotificationsPluginImpl.requestNotificationsPermission();
final initializationSettingsAndroid = AndroidInitializationSettings('ic_notification_white');
final initializationSettingsDarwin = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
onDidReceiveLocalNotification: _receiveLocalDarwinNotification,
notificationCategories: getDarwinNotificationCategories(),
);
final initializationSettingsLinux = LinuxInitializationSettings(defaultActionName: 'Open notification');
final initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
linux: initializationSettingsLinux,
);
flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _receiveLocalNotification,
onDidReceiveBackgroundNotificationResponse: _notificationTapBackground,
);
final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
if (appLaunchNotification != null) {
// Use has launched SCN by clicking on a loca notifiaction, if it was a summary or message notifiaction open the corresponding screen
// This is android only
//TODO same on iOS, somehow??
ApplicationLog.info('App launched by notification: ${appLaunchNotification.notificationResponse?.id}');
_handleNotificationClickAction(appLaunchNotification.notificationResponse?.payload, Duration(milliseconds: 600));
}
}
ApplicationLog.debug('[INIT] Application started'); ApplicationLog.debug('[INIT] Application started');
runApp( runApp(
@ -155,6 +202,7 @@ void main() async {
ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false), ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false),
ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false), ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false),
ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false), ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false),
ChangeNotifierProvider(create: (context) => AppSettings(), lazy: false),
], ],
child: SCNApp(), child: SCNApp(),
), ),
@ -164,6 +212,8 @@ void main() async {
class SCNApp extends StatelessWidget { class SCNApp extends StatelessWidget {
SCNApp({super.key}); SCNApp({super.key});
static var materialKey = GlobalKey<NavigatorState>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ToastificationWrapper( return ToastificationWrapper(
@ -174,6 +224,7 @@ class SCNApp extends StatelessWidget {
), ),
child: Consumer<AppTheme>( child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp( builder: (context, appTheme, child) => MaterialApp(
navigatorKey: SCNApp.materialKey,
title: 'SimpleCloudNotifier', title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver], navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
theme: ThemeData( theme: ThemeData(
@ -188,6 +239,12 @@ class SCNApp extends StatelessWidget {
} }
} }
@pragma('vm:entry-point')
void _notificationTapBackground(NotificationResponse notificationResponse) {
// I think only iOS triggers this, TODO
ApplicationLog.info('Received local notification<vm:entry-point>: ${notificationResponse.id}');
}
void setFirebaseToken(String fcmToken) async { void setFirebaseToken(String fcmToken) async {
final acc = AppAuth(); final acc = AppAuth();
@ -224,18 +281,129 @@ void setFirebaseToken(String fcmToken) async {
} }
} }
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async { Future<void> _onBackgroundMessage(RemoteMessage message) async {
// a firebase message was received while the app was in the background or terminated
await _receiveMessage(message, false); await _receiveMessage(message, false);
} }
@pragma('vm:entry-point')
void _onForegroundMessage(RemoteMessage message) { void _onForegroundMessage(RemoteMessage message) {
// a firebase message was received while the app was in the foreground
_receiveMessage(message, true); _receiveMessage(message, true);
} }
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async { Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
// ensure init try {
Hive.openBox<SCNLog>('scn-logs'); // ensure globals init
if (!Globals().isInitialized) {
print('[LATE-INIT] Init Globals() - to ensure working _receiveMessage($foreground)...');
await Globals().init();
}
// ensure hive init
if (!Hive.isBoxOpen('scn-logs')) {
print('[LATE-INIT] Init Hive - to ensure working _receiveMessage($foreground)...');
await Hive.initFlutter();
Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter());
}
print('[LATE-INIT] Ensure hive boxes are open for _receiveMessage($foreground)...');
await Hive.openBox<SCNLog>('scn-logs');
await Hive.openBox<FBMessage>('scn-fb-messages');
await Hive.openBox<SCNMessage>('scn-message-cache');
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null);
return;
}
ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}'); ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}');
FBMessageLog.insert(message);
String scn_msg_id;
try {
scn_msg_id = message.data['scn_msg_id'] as String;
final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000);
final title = message.data['title'] as String;
final channel = message.data['channel'] as String;
final channel_id = message.data['channel_id'] as String;
final body = message.data['body'] as String;
Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp);
} catch (exc, trace) {
ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null);
return;
}
try {
FBMessageLog.insert(message);
} catch (exc, trace) {
ApplicationLog.error('Failed to persist received FB message' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null);
return;
}
try {
final msg = await APIClient.getMessage(AppAuth(), scn_msg_id);
SCNDataCache().addToMessageCache([msg]);
if (foreground) AppEvents().notifyMessageReceivedListeners(msg);
} catch (exc, trace) {
ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace);
return;
}
}
void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) {
//TODO iOS?
ApplicationLog.info('Received local notification<darwin>: $id -> [$title]');
}
void _receiveLocalNotification(NotificationResponse details) {
// User has tapped a flutter_local notification, while the app was running
ApplicationLog.info('Tapped local notification: [[${details.id} | ${details.actionId} | ${details.input} | ${details.notificationResponseType} | ${details.payload}]]');
_handleNotificationClickAction(details.payload, Duration.zero);
}
void _handleNotificationClickAction(String? payload, Duration delay) {
final parts = payload?.split('\n') ?? [];
if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') {
final messageID = parts[1];
() async {
await Future.delayed(delay);
SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}');
Navi.push(SCNApp.materialKey.currentContext!, () => MessageViewPage(messageID: messageID, preloadedData: null));
});
}();
} else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') {
final channelID = parts[1];
() async {
await Future.delayed(delay);
SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}');
Navi.push(SCNApp.materialKey.currentContext!, () => ChannelViewPage(channelID: channelID, preloadedData: null, needsReload: null));
});
}();
}
}
List<DarwinNotificationCategory> getDarwinNotificationCategories() {
return <DarwinNotificationCategory>[
//TODO ?!?
];
} }

View File

@ -66,7 +66,7 @@ class APIError {
factory APIError.fromJson(Map<String, dynamic> json) { factory APIError.fromJson(Map<String, dynamic> json) {
return APIError( return APIError(
success: json['success'] as bool, success: json['success'] as bool,
error: (json['error'] as double).toInt(), error: (json['error'] as num).toInt(),
errhighlight: json['errhighlight'] as String, errhighlight: json['errhighlight'] as String,
message: json['message'] as String, message: json['message'] as String,
); );

View File

@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable {
@HiveField(10) @HiveField(10)
final String ownerUserID; final String ownerUserID;
@HiveField(11) @HiveField(11)
final String internalName; final String internalName; // = InternalName, used for sending, normalized, cannot be changed
@HiveField(12) @HiveField(12)
final String displayName; final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName
@HiveField(13) @HiveField(13)
final String? descriptionName; final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil
@HiveField(14) @HiveField(14)
final String? subscribeKey; final String? subscribeKey;
@HiveField(15) @HiveField(15)
@ -70,11 +70,21 @@ class Channel extends HiveObject implements FieldDebuggable {
('messagesSent', '${this.messagesSent}'), ('messagesSent', '${this.messagesSent}'),
]; ];
} }
ChannelPreview toPreview() {
return ChannelPreview(
channelID: this.channelID,
ownerUserID: this.ownerUserID,
internalName: this.internalName,
displayName: this.displayName,
descriptionName: this.descriptionName,
);
}
} }
class ChannelWithSubscription { class ChannelWithSubscription {
final Channel channel; final Channel channel;
final Subscription subscription; final Subscription? subscription;
ChannelWithSubscription({ ChannelWithSubscription({
required this.channel, required this.channel,
@ -84,7 +94,7 @@ class ChannelWithSubscription {
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) { factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
return ChannelWithSubscription( return ChannelWithSubscription(
channel: Channel.fromJson(json), channel: Channel.fromJson(json),
subscription: Subscription.fromJson(json['subscription'] as Map<String, dynamic>), subscription: json['subscription'] == null ? null : Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
); );
} }

View File

@ -32,6 +32,19 @@ class Client {
); );
} }
Map<String, dynamic> toJson() {
return {
'client_id': clientID,
'user_id': userID,
'type': type,
'fcm_token': fcmToken,
'timestamp_created': timestampCreated,
'agent_model': agentModel,
'agent_version': agentVersion,
'name': name,
};
}
static List<Client> fromJsonArray(List<dynamic> jsonArr) { static List<Client> fromJsonArray(List<dynamic> jsonArr) {
return jsonArr.map<Client>((e) => Client.fromJson(e as Map<String, dynamic>)).toList(); return jsonArr.map<Client>((e) => Client.fromJson(e as Map<String, dynamic>)).toList();
} }

View File

@ -1,10 +1,10 @@
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/interfaces.dart'; import 'package:simplecloudnotifier/state/interfaces.dart';
part 'message.g.dart'; part 'scn_message.g.dart';
@HiveType(typeId: 105) @HiveType(typeId: 105)
class Message extends HiveObject implements FieldDebuggable { class SCNMessage extends HiveObject implements FieldDebuggable {
@HiveField(0) @HiveField(0)
final String messageID; final String messageID;
@ -33,7 +33,7 @@ class Message extends HiveObject implements FieldDebuggable {
@HiveField(21) @HiveField(21)
final bool trimmed; final bool trimmed;
Message({ SCNMessage({
required this.messageID, required this.messageID,
required this.senderUserID, required this.senderUserID,
required this.channelInternalName, required this.channelInternalName,
@ -49,8 +49,8 @@ class Message extends HiveObject implements FieldDebuggable {
required this.trimmed, required this.trimmed,
}); });
factory Message.fromJson(Map<String, dynamic> json) { factory SCNMessage.fromJson(Map<String, dynamic> json) {
return Message( return SCNMessage(
messageID: json['message_id'] as String, messageID: json['message_id'] as String,
senderUserID: json['sender_user_id'] as String, senderUserID: json['sender_user_id'] as String,
channelInternalName: json['channel_internal_name'] as String, channelInternalName: json['channel_internal_name'] as String,
@ -67,10 +67,10 @@ class Message extends HiveObject implements FieldDebuggable {
); );
} }
static (String, List<Message>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) { static (String, List<SCNMessage>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) {
final npt = data[keyToken] as String; final npt = data[keyToken] as String;
final messages = (data[keyMessages] as List<dynamic>).map<Message>((e) => Message.fromJson(e as Map<String, dynamic>)).toList(); final messages = (data[keyMessages] as List<dynamic>).map<SCNMessage>((e) => SCNMessage.fromJson(e as Map<String, dynamic>)).toList();
return (npt, messages); return (npt, messages);
} }

View File

@ -1,22 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message.dart'; part of 'scn_message.dart';
// ************************************************************************** // **************************************************************************
// TypeAdapterGenerator // TypeAdapterGenerator
// ************************************************************************** // **************************************************************************
class MessageAdapter extends TypeAdapter<Message> { class SCNMessageAdapter extends TypeAdapter<SCNMessage> {
@override @override
final int typeId = 105; final int typeId = 105;
@override @override
Message read(BinaryReader reader) { SCNMessage read(BinaryReader reader) {
final numOfFields = reader.readByte(); final numOfFields = reader.readByte();
final fields = <int, dynamic>{ final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
}; };
return Message( return SCNMessage(
messageID: fields[0] as String, messageID: fields[0] as String,
senderUserID: fields[10] as String, senderUserID: fields[10] as String,
channelInternalName: fields[11] as String, channelInternalName: fields[11] as String,
@ -34,7 +34,7 @@ class MessageAdapter extends TypeAdapter<Message> {
} }
@override @override
void write(BinaryWriter writer, Message obj) { void write(BinaryWriter writer, SCNMessage obj) {
writer writer
..writeByte(13) ..writeByte(13)
..writeByte(0) ..writeByte(0)
@ -71,7 +71,7 @@ class MessageAdapter extends TypeAdapter<Message> {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is MessageAdapter && other is SCNMessageAdapter &&
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
typeId == other.typeId; typeId == other.typeId;
} }

View File

@ -63,6 +63,33 @@ class User {
maxUserMessageIDLength: json['max_user_message_id_length'] as int, maxUserMessageIDLength: json['max_user_message_id_length'] as int,
); );
} }
Map<String, dynamic> toJson() {
return {
'user_id': userID,
'username': username,
'timestamp_created': timestampCreated,
'timestamp_lastread': timestampLastRead,
'timestamp_lastsent': timestampLastSent,
'messages_sent': messagesSent,
'quota_used': quotaUsed,
'quota_remaining': quotaRemaining,
'quota_max': quotaPerDay,
'is_pro': isPro,
'default_channel': defaultChannel,
'max_body_size': maxBodySize,
'max_title_length': maxTitleLength,
'default_priority': defaultPriority,
'max_channel_name_length': maxChannelNameLength,
'max_channel_description_length': maxChannelDescriptionLength,
'max_sender_name_length': maxSenderNameLength,
'max_user_message_id_length': maxUserMessageIDLength,
};
}
UserPreview toPreview() {
return UserPreview(userID: userID, username: username);
}
} }
class UserWithClientsAndKeys { class UserWithClientsAndKeys {

View File

@ -59,8 +59,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
return Scaffold( return Scaffold(
appBar: SCNAppBar( appBar: SCNAppBar(
title: null, title: null,
showDebug: true, showSearch: _selectedIndex == 0,
showSearch: _selectedIndex == 0 || _selectedIndex == 1,
showShare: false, showShare: false,
showThemeSwitch: true, showThemeSwitch: true,
), ),
@ -77,6 +76,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
bottomNavigationBar: _buildNavBar(context), bottomNavigationBar: _buildNavBar(context),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: HidableFAB( floatingActionButton: HidableFAB(
heroTag: 'fab_main',
onPressed: _onFABTapped, onPressed: _onFABTapped,
icon: FontAwesomeIcons.solidPaperPlaneTop, icon: FontAwesomeIcons.solidPaperPlaneTop,
), ),

View File

@ -1,12 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class ChannelRootPage extends StatefulWidget { class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key, required this.isVisiblePage}); const ChannelRootPage({super.key, required this.isVisiblePage});
@ -17,11 +20,13 @@ class ChannelRootPage extends StatefulWidget {
State<ChannelRootPage> createState() => _ChannelRootPageState(); State<ChannelRootPage> createState() => _ChannelRootPageState();
} }
class _ChannelRootPageState extends State<ChannelRootPage> { class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false; bool _isInitialized = false;
bool _reloadEnqueued = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -31,10 +36,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
if (widget.isVisiblePage && !_isInitialized) _realInitState(); if (widget.isVisiblePage && !_isInitialized) _realInitState();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
}
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose'); ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose(); _pagingController.dispose();
Navi.modalRouteObserver.unsubscribe(this);
super.dispose(); super.dispose();
} }
@ -51,6 +63,24 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
} }
} }
@override
void didPush() {
// ...
}
@override
void didPopNext() {
if (_reloadEnqueued) {
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
() async {
_reloadEnqueued = false;
AppBarState().setLoadingIndeterminate(true);
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
await _backgroundRefresh();
}();
}
}
void _realInitState() { void _realInitState() {
ApplicationLog.debug('ChannelRootPage::_realInitState'); ApplicationLog.debug('ChannelRootPage::_realInitState');
_pagingController.refresh(); _pagingController.refresh();
@ -68,9 +98,9 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
} }
try { try {
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList(); final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) { } catch (exc, trace) {
@ -94,13 +124,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
AppBarState().setLoadingIndeterminate(true); AppBarState().setLoadingIndeterminate(true);
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList(); final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); setState(() {
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
});
} catch (exc, trace) { } catch (exc, trace) {
_pagingController.error = exc.toString(); setState(() {
_pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
} finally { } finally {
AppBarState().setLoadingIndeterminate(false); AppBarState().setLoadingIndeterminate(false);
@ -109,19 +143,35 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( return Scaffold(
onRefresh: () => Future.sync( body: RefreshIndicator(
() => _pagingController.refresh(), onRefresh: () => Future.sync(
), () => _pagingController.refresh(),
child: PagedListView<int, Channel>( ),
pagingController: _pagingController, child: PagedListView<int, ChannelWithSubscription>(
builderDelegate: PagedChildBuilderDelegate<Channel>( pagingController: _pagingController,
itemBuilder: (context, item, index) => ChannelListItem( builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
channel: item, itemBuilder: (context, item, index) => ChannelListItem(
onPressed: () {/*TODO*/}, channel: item.channel,
subscription: item.subscription,
onPressed: () {
Navi.push(context, () => ChannelViewPage(channelID: item.channel.channelID, preloadedData: (item.channel, item.subscription), needsReload: _enqueueReload));
},
),
), ),
), ),
), ),
floatingActionButton: FloatingActionButton(
heroTag: 'fab_channel_list_qr',
onPressed: () {
//TODO scan qr code to subscribe channel
},
child: const Icon(FontAwesomeIcons.qrcode),
),
); );
} }
void _enqueueReload() {
_reloadEnqueued = true;
}
} }

View File

@ -1,10 +1,18 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class ChannelListItem extends StatefulWidget { class ChannelListItem extends StatefulWidget {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
@ -12,10 +20,12 @@ class ChannelListItem extends StatefulWidget {
const ChannelListItem({ const ChannelListItem({
required this.channel, required this.channel,
required this.onPressed, required this.onPressed,
required this.subscription,
super.key, super.key,
}); });
final Channel channel; final Channel channel;
final Subscription? subscription;
final Null Function() onPressed; final Null Function() onPressed;
@override @override
@ -23,7 +33,7 @@ class ChannelListItem extends StatefulWidget {
} }
class _ChannelListItemState extends State<ChannelListItem> { class _ChannelListItemState extends State<ChannelListItem> {
Message? lastMessage; SCNMessage? lastMessage;
@override @override
void initState() { void initState() {
@ -32,8 +42,10 @@ class _ChannelListItemState extends State<ChannelListItem> {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
if (acc.isAuth()) { if (acc.isAuth()) {
lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() async { () async {
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]); final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID]));
setState(() { setState(() {
lastMessage = channelMessages.firstOrNull; lastMessage = channelMessages.firstOrNull;
}); });
@ -49,39 +61,56 @@ class _ChannelListItemState extends State<ChannelListItem> {
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color, color: Theme.of(context).cardTheme.color,
child: InkWell( child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: widget.onPressed, onTap: widget.onPressed,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( _buildIcon(context),
children: [ SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Column(
widget.channel.displayName, crossAxisAlignment: CrossAxisAlignment.stretch,
style: const TextStyle(fontWeight: FontWeight.bold), children: [
Row(
children: [
Expanded(
child: Text(
widget.channel.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Text(
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()),
style: const TextStyle(fontSize: 14),
),
],
), ),
), SizedBox(height: 4),
Text( Row(
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()), crossAxisAlignment: CrossAxisAlignment.end,
style: const TextStyle(fontSize: 14), children: [
), Expanded(
], child: Text(
_preformatTitle(lastMessage),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
),
Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
],
),
],
),
), ),
SizedBox(height: 4), SizedBox(width: 4),
Row( GestureDetector(
crossAxisAlignment: CrossAxisAlignment.end, onTap: () {
children: [ Navi.push(context, () => ChannelMessageViewPage(channel: this.widget.channel));
Expanded( },
child: Text( child: Padding(
lastMessage?.title ?? '...', padding: const EdgeInsets.all(8),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
), ),
),
Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
],
), ),
], ],
), ),
@ -89,4 +118,21 @@ class _ChannelListItemState extends State<ChannelListItem> {
), ),
); );
} }
String _preformatTitle(SCNMessage? message) {
if (message == null) return '...';
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
}
Widget _buildIcon(BuildContext context) {
if (widget.subscription == null) {
return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel)
} else if (widget.subscription!.confirmed) {
return Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel)
} else {
return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested
}
}
} }

View File

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:provider/provider.dart';
class ChannelMessageViewPage extends StatefulWidget {
const ChannelMessageViewPage({
required this.channel,
super.key,
});
final Channel channel;
@override
State<ChannelMessageViewPage> createState() => _ChannelMessageViewPageState();
}
class _ChannelMessageViewPageState extends State<ChannelMessageViewPage> {
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start ChannelMessageViewPage::_pagingController::_fetchPage [ ${thisPageToken} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: MessageFilter(channelIDs: [this.widget.channel.channelID]));
SCNDataCache().addToMessageCache(newItems); // no await
if (npt == '@end') {
_pagingController.appendLastPage(newItems);
} else {
_pagingController.appendPage(newItems, npt);
}
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list channel-messages: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: this.widget.channel.displayName,
showSearch: false,
showShare: false,
child: _buildMessageList(context),
);
}
Widget _buildMessageList(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
itemBuilder: (context, item, index) => MessageListItem(
message: item,
allChannels: {this.widget.channel.channelID: this.widget.channel},
onPressed: () {
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
},
),
),
),
),
);
}
}

View File

@ -0,0 +1,611 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
import 'package:provider/provider.dart';
class ChannelViewPage extends StatefulWidget {
const ChannelViewPage({
required this.channelID,
required this.preloadedData,
required this.needsReload,
super.key,
});
final String channelID;
final (Channel, Subscription?)? preloadedData;
final void Function()? needsReload;
@override
State<ChannelViewPage> createState() => _ChannelViewPageState();
}
enum EditState { none, editing, saving }
enum ChannelViewPageInitState { loading, okay, error }
class _ChannelViewPageState extends State<ChannelViewPage> {
late ImmediateFuture<String?> _futureSubscribeKey;
late ImmediateFuture<List<(Subscription, UserPreview?)>> _futureSubscriptions;
late ImmediateFuture<UserPreview> _futureOwner;
final TextEditingController _ctrlDisplayName = TextEditingController();
final TextEditingController _ctrlDescriptionName = TextEditingController();
int _loadingIndeterminateCounter = 0;
EditState _editDisplayName = EditState.none;
String? _displayNameOverride = null;
EditState _editDescriptionName = EditState.none;
String? _descriptionNameOverride = null;
ChannelPreview? channelPreview;
Channel? channel;
Subscription? subscription;
ChannelViewPageInitState loadingState = ChannelViewPageInitState.loading;
String errorMessage = '';
@override
void initState() {
_initStateAsync();
super.initState();
}
@override
void _initStateAsync() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (widget.preloadedData != null) {
channelPreview = widget.preloadedData!.$1.toPreview();
channel = widget.preloadedData!.$1;
subscription = widget.preloadedData!.$2;
} else {
try {
var p = await APIClient.getChannelPreview(userAcc, widget.channelID);
channelPreview = p;
if (p.ownerUserID == userAcc.userID) {
var r = await APIClient.getChannel(userAcc, widget.channelID);
channel = r.channel;
subscription = r.subscription;
} else {
channel = null;
subscription = null; //TODO get own subscription on this channel, even though its foreign channel
}
} catch (exc, trace) {
ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to load data');
this.errorMessage = 'Failed to load data: ' + exc.toString();
this.loadingState = ChannelViewPageInitState.error;
return;
}
}
this.loadingState = ChannelViewPageInitState.okay;
assert(channelPreview != null);
if (this.channelPreview!.ownerUserID == userAcc.userID) {
if (this.channel != null && this.channel!.subscribeKey != null) {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(this.channel!.subscribeKey);
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubscribeKey(userAcc));
}
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofFuture(_listSubscriptions(userAcc));
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(null);
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofValue([]);
}
if (this.channelPreview!.ownerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc));
}
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID));
}
}
@override
void dispose() {
_ctrlDisplayName.dispose();
_ctrlDescriptionName.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
Widget child;
if (loadingState == ChannelViewPageInitState.loading) {
child = Center(child: CircularProgressIndicator());
} else if (loadingState == ChannelViewPageInitState.error) {
child = Center(child: Text('Error: ' + errorMessage)); //TODO better error
} else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) {
child = _buildOwnedChannelView(context, this.channel!);
} else {
child = _buildForeignChannelView(context, this.channelPreview!);
}
return SCNScaffold(
title: 'Channel',
showSearch: false,
showShare: false,
child: child,
);
}
Widget _buildOwnedChannelView(BuildContext context, Channel channel) {
final isSubscribed = (subscription != null && subscription!.confirmed);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildQRCode(context),
SizedBox(height: 8),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'ChannelID',
values: [channel.channelID],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputNumeric,
title: 'InternalName',
values: [channel.internalName],
),
_buildDisplayNameCard(context, true),
_buildDescriptionNameCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (own)',
values: [_formatSubscriptionStatus(this.subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
),
_buildForeignSubscriptions(context),
_buildOwnerCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidEnvelope,
title: 'Messages',
values: [channel.messagesSent.toString()],
mainAction: () {
Navi.push(context, () => ChannelMessageViewPage(channel: channel));
},
),
],
),
),
);
}
Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) {
final isSubscribed = (subscription != null && subscription!.confirmed);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 8),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'ChannelID',
values: [channel.channelID],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputNumeric,
title: 'InternalName',
values: [channel.internalName],
),
_buildDisplayNameCard(context, false),
_buildDescriptionNameCard(context, false),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (own)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
),
_buildForeignSubscriptions(context),
_buildOwnerCard(context, false),
],
),
),
);
}
Widget _buildForeignSubscriptions(BuildContext context) {
return FutureBuilder(
future: _futureSubscriptions.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != subscription?.subscriptionID))
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSuccessor,
title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')',
values: [_formatSubscriptionStatus(sub)],
iconActions: _getForeignSubActions(sub),
),
],
);
} else {
return SizedBox();
}
},
);
}
Widget _buildOwnerCard(BuildContext context, bool isOwned) {
return FutureBuilder(
future: _futureOwner.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Owner',
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Owner',
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : '')],
);
}
},
);
}
Widget _buildQRCode(BuildContext context) {
return FutureBuilder(
future: _futureSubscribeKey.future,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
var text = 'TODO' + '\n' + channel!.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?)
return GestureDetector(
onTap: () {
Share.share(text, subject: _displayNameOverride ?? channel!.displayName);
},
child: Center(
child: QrImageView(
data: text,
version: QrVersions.auto,
size: 300.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
),
);
} else if (snapshot.hasData && snapshot.data == null) {
return const SizedBox(
width: 300.0,
height: 300.0,
child: Center(child: Icon(FontAwesomeIcons.solidSnake, size: 64)),
);
} else {
return const SizedBox(
width: 300.0,
height: 300.0,
child: Center(child: CircularProgressIndicator()),
);
}
},
);
}
Widget _buildDisplayNameCard(BuildContext context, bool isOwned) {
if (_editDisplayName == EditState.editing) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(
child: TextField(
autofocus: true,
controller: _ctrlDisplayName,
decoration: new InputDecoration.collapsed(hintText: 'DisplayName'),
),
),
SizedBox(width: 12),
SizedBox(width: 4),
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDisplayName),
],
),
),
);
} else if (_editDisplayName == EditState.none) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputText,
title: 'DisplayName',
values: [_displayNameOverride ?? channelPreview!.displayName],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [],
);
} else if (_editDisplayName == EditState.saving) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(child: SizedBox()),
SizedBox(width: 12),
SizedBox(width: 4),
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
],
),
),
);
} else {
throw 'Invalid EditDisplayNameState: $_editDisplayName';
}
}
Widget _buildDescriptionNameCard(BuildContext context, bool isOwned) {
if (_editDescriptionName == EditState.editing) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(
child: TextField(
autofocus: true,
controller: _ctrlDescriptionName,
decoration: new InputDecoration.collapsed(hintText: 'Description'),
),
),
SizedBox(width: 12),
SizedBox(width: 4),
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDescriptionName),
],
),
),
);
} else if (_editDescriptionName == EditState.none) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputPipe,
title: 'Description',
values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [],
);
} else if (_editDescriptionName == EditState.saving) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(child: SizedBox()),
SizedBox(width: 12),
SizedBox(width: 4),
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
],
),
),
);
} else {
throw 'Invalid EditDescriptionNameState: $_editDescriptionName';
}
}
void _subscribe() {
//TODO
}
void _unsubscribe() {
//TODO
}
void _showEditDisplayName() {
setState(() {
_ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? '';
_editDisplayName = EditState.editing;
if (_editDescriptionName == EditState.editing) _editDescriptionName = EditState.none;
});
}
void _saveDisplayName() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
final newName = _ctrlDisplayName.text;
try {
setState(() {
_editDisplayName = EditState.saving;
});
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, displayName: newName);
setState(() {
_editDisplayName = EditState.none;
_displayNameOverride = newChannel.channel.displayName;
});
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to save DisplayName: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to save DisplayName');
}
}
void _showEditDescriptionName() {
setState(() {
_ctrlDescriptionName.text = _descriptionNameOverride ?? channelPreview?.descriptionName ?? '';
_editDescriptionName = EditState.editing;
if (_editDisplayName == EditState.editing) _editDisplayName = EditState.none;
});
}
void _saveDescriptionName() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
final newName = _ctrlDescriptionName.text;
try {
setState(() {
_editDescriptionName = EditState.saving;
});
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, descriptionName: newName);
setState(() {
_editDescriptionName = EditState.none;
_descriptionNameOverride = newChannel.channel.descriptionName ?? '';
});
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to save DescriptionName: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to save DescriptionName');
}
}
void _cancelForeignSubscription(Subscription sub) {
//TODO
}
void _confirmForeignSubscription(Subscription sub) {
//TODO
}
void _denyForeignSubscription(Subscription sub) {
//TODO
}
String _formatSubscriptionStatus(Subscription? subscription) {
if (subscription == null) {
return 'Not Subscribed';
} else if (subscription.confirmed) {
return 'Subscribed';
} else {
return 'Requested';
}
}
Future<String?> _getSubscribeKey(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
var channel = await APIClient.getChannel(auth, widget.channelID);
//await Future.delayed(const Duration(seconds: 10), () {});
return channel.channel.subscribeKey;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
Future<List<(Subscription, UserPreview?)>> _listSubscriptions(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
var subs = await APIClient.getChannelSubscriptions(auth, widget.channelID);
var userMap = {for (var v in (await Future.wait(subs.map((e) => e.subscriberUserID).toSet().map((e) => APIClient.getUserPreview(auth, e)).toList()))) v.userID: v};
//await Future.delayed(const Duration(seconds: 10), () {});
return subs.map((e) => (e, userMap[e.subscriberUserID] ?? null)).toList();
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
Future<UserPreview> _getOwner(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
final owner = APIClient.getUserPreview(auth, channelPreview!.ownerUserID);
//await Future.delayed(const Duration(seconds: 10), () {});
return owner;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
List<(IconData, void Function())> _getForeignSubActions(Subscription sub) {
if (sub.confirmed) {
return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))];
} else {
return [
(FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)),
(FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)),
];
}
}
void _incLoadingIndeterminateCounter(int delta) {
setState(() {
_loadingIndeterminateCounter += delta;
AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0);
});
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/utils/notifier.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -52,6 +53,12 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
onPressed: _sendTokenToServer, onPressed: _sendTokenToServer,
text: 'Send FCM Token to Server', text: 'Send FCM Token to Server',
), ),
SizedBox(height: 20),
UI.button(
big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null),
text: 'Show local notification',
),
], ],
), ),
), ),

View File

@ -30,7 +30,6 @@ class _DebugMainPageState extends State<DebugMainPage> {
return SCNScaffold( return SCNScaffold(
title: 'Debug', title: 'Debug',
showSearch: false, showSearch: false,
showDebug: false,
child: Column( child: Column(
children: [ children: [
Padding( Padding(

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_hive.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_hive.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
@ -36,7 +36,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
_buildSharedPrefCard(context), _buildSharedPrefCard(context),
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'), _buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
_buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'), _buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'),
_buildHiveCard(context, () => Hive.box<Message>('scn-message-cache'), 'scn-message-cache'), _buildHiveCard(context, () => Hive.box<SCNMessage>('scn-message-cache'), 'scn-message-cache'),
_buildHiveCard(context, () => Hive.box<Channel>('scn-channel-cache'), 'scn-channel-cache'), _buildHiveCard(context, () => Hive.box<Channel>('scn-channel-cache'), 'scn-channel-cache'),
_buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'), _buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'),
], ],
@ -71,7 +71,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box<FBMessage>(boxname))); Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: boxFunc()));
}, },
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -16,7 +16,6 @@ class DebugHiveBoxPage extends StatelessWidget {
return SCNScaffold( return SCNScaffold(
title: 'Hive: ' + boxName, title: 'Hive: ' + boxName,
showSearch: false, showSearch: false,
showDebug: false,
child: ListView.separated( child: ListView.separated(
itemCount: box.length, itemCount: box.length,
itemBuilder: (context, listIndex) { itemBuilder: (context, listIndex) {
@ -24,8 +23,9 @@ class DebugHiveBoxPage extends StatelessWidget {
onTap: () { onTap: () {
Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!)); Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
}, },
child: ListTile( child: Container(
title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)), padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
), ),
); );
}, },

View File

@ -13,11 +13,13 @@ class DebugHiveEntryPage extends StatelessWidget {
return SCNScaffold( return SCNScaffold(
title: 'HiveEntry', title: 'HiveEntry',
showSearch: false, showSearch: false,
showDebug: false,
child: ListView.separated( child: ListView.separated(
itemCount: fields.length, itemCount: fields.length,
itemBuilder: (context, listIndex) { itemBuilder: (context, listIndex) {
return ListTile( return ListTile(
dense: true,
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)), title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")), subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")),
); );

View File

@ -13,7 +13,6 @@ class DebugSharedPrefPage extends StatelessWidget {
return SCNScaffold( return SCNScaffold(
title: 'SharedPreferences', title: 'SharedPreferences',
showSearch: false, showSearch: false,
showDebug: false,
child: ListView.separated( child: ListView.separated(
itemCount: sharedPref.getKeys().length, itemCount: sharedPref.getKeys().length,
itemBuilder: (context, listIndex) { itemBuilder: (context, listIndex) {

View File

@ -16,7 +16,6 @@ class DebugRequestViewPage extends StatelessWidget {
return SCNScaffold( return SCNScaffold(
title: 'Request', title: 'Request',
showSearch: false, showSearch: false,
showDebug: false,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -61,6 +60,7 @@ class DebugRequestViewPage extends StatelessWidget {
onPressed: () { onPressed: () {
Clipboard.setData(new ClipboardData(text: title)); Clipboard.setData(new ClipboardData(text: title));
Toaster.info("Clipboard", 'Copied text to Clipboard'); Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${title}\n================= [/CLIPBOARD] =================');
}, },
icon: FontAwesomeIcons.copy, icon: FontAwesomeIcons.copy,
), ),

View File

@ -0,0 +1,36 @@
import 'package:flutter/src/widgets/icon_data.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
enum MessageFilterChipletType {
search,
channel,
sender,
timeRange,
priority,
sendkey,
}
class MessageFilterChiplet {
final String label; // display value
final dynamic value; // search/api value
final MessageFilterChipletType type;
MessageFilterChiplet({required this.label, required this.value, required this.type});
IconData? icon() {
switch (type) {
case MessageFilterChipletType.search:
return FontAwesomeIcons.magnifyingGlass;
case MessageFilterChipletType.channel:
return FontAwesomeIcons.snake;
case MessageFilterChipletType.sender:
return FontAwesomeIcons.signature;
case MessageFilterChipletType.timeRange:
return FontAwesomeIcons.timer;
case MessageFilterChipletType.priority:
return FontAwesomeIcons.bolt;
case MessageFilterChipletType.sendkey:
return FontAwesomeIcons.gearCode;
}
}
}

View File

@ -1,15 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
class MessageListPage extends StatefulWidget { class MessageListPage extends StatefulWidget {
@ -17,28 +20,28 @@ class MessageListPage extends StatefulWidget {
final bool isVisiblePage; final bool isVisiblePage;
//TODO reload on switch to tab
//TODO reload on app to foreground
@override @override
State<MessageListPage> createState() => _MessageListPageState(); State<MessageListPage> createState() => _MessageListPageState();
} }
class _MessageListPageState extends State<MessageListPage> with RouteAware { class _MessageListPageState extends State<MessageListPage> with RouteAware {
static const _pageSize = 128;
late final AppLifecycleListener _lifecyleListener; late final AppLifecycleListener _lifecyleListener;
PagingController<String, Message> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start'); PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
Map<String, Channel>? _channels = null; Map<String, Channel>? _channels = null;
bool _isInitialized = false; bool _isInitialized = false;
List<MessageFilterChiplet> _filterChiplets = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
AppEvents().subscribeFilterListener(_onAddFilter);
AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification);
_pagingController.addPageRequestListener(_fetchPage); _pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) _realInitState(); if (widget.isVisiblePage && !_isInitialized) _realInitState();
@ -64,18 +67,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
void _realInitState() { void _realInitState() {
ApplicationLog.debug('MessageListPage::_realInitState'); ApplicationLog.debug('MessageListPage::_realInitState');
final chnCache = Hive.box<Channel>('scn-channel-cache'); if (SCNDataCache().hasMessagesAndChannels()) {
final msgCache = Hive.box<Message>('scn-message-cache');
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
// ==== Use cache values - and refresh in background // ==== Use cache values - and refresh in background
_channels = <String, Channel>{for (var v in chnCache.values) v.channelID: v}; _channels = SCNDataCache().getChannelMap();
final cacheMessages = msgCache.values.toList(); _pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null);
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
_pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null);
_backgroundRefresh(true); _backgroundRefresh(true);
} else { } else {
@ -95,6 +92,8 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('MessageListPage::dispose'); ApplicationLog.debug('MessageListPage::dispose');
AppEvents().unsubscribeFilterListener(_onAddFilter);
AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification);
Navi.modalRouteObserver.unsubscribe(this); Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose(); _pagingController.dispose();
_lifecyleListener.dispose(); _lifecyleListener.dispose();
@ -108,17 +107,22 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override @override
void didPopNext() { void didPopNext() {
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); if (AppSettings().backgroundRefreshMessageListOnPop) {
_backgroundRefresh(false); ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
_backgroundRefresh(false);
}
} }
void _onLifecycleResume() { void _onLifecycleResume() {
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume && widget.isVisiblePage) {
_backgroundRefresh(false); ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
_backgroundRefresh(false);
}
} }
Future<void> _fetchPage(String thisPageToken) async { Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]'); ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
@ -132,12 +136,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel}; _channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
_setChannelCache(channels); // no await SCNDataCache().setChannelCache(channels); // no await
} }
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize); final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: _getFilter());
_addToMessageCache(newItems); // no await SCNDataCache().addToMessageCache(newItems); // no await
ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]'); ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]');
@ -154,6 +158,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Future<void> _backgroundRefresh(bool fullReplaceState) async { Future<void> _backgroundRefresh(bool fullReplaceState) async {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)'); ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)');
@ -167,12 +172,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
setState(() { setState(() {
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel}; _channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
}); });
_setChannelCache(channels); // no await SCNDataCache().setChannelCache(channels); // no await
} }
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize); final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: cfg.messagePageSize);
_addToMessageCache(newItems); // no await SCNDataCache().addToMessageCache(newItems); // no await
if (fullReplaceState) { if (fullReplaceState) {
// fully replace/reset state // fully replace/reset state
@ -221,49 +226,106 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4), padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator( child: Column(
onRefresh: () => Future.sync( crossAxisAlignment: CrossAxisAlignment.stretch,
() => _pagingController.refresh(), children: [
), if (_filterChiplets.isNotEmpty)
child: PagedListView<String, Message>( Wrap(
pagingController: _pagingController, alignment: WrapAlignment.start,
builderDelegate: PagedChildBuilderDelegate<Message>( spacing: 5.0,
itemBuilder: (context, item, index) => MessageListItem( children: [
message: item, for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet),
allChannels: _channels ?? {}, ],
onPressed: () { ),
Navi.push(context, () => MessageViewPage(message: item)); Expanded(
}, child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
itemBuilder: (context, item, index) => MessageListItem(
message: item,
allChannels: _channels ?? {},
onPressed: () {
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
},
),
),
),
), ),
), ),
), ],
), ),
); );
} }
Future<void> _setChannelCache(List<ChannelWithSubscription> channels) async { Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) {
final cache = Hive.box<Channel>('scn-channel-cache'); return Padding(
padding: const EdgeInsets.fromLTRB(0, 2, 0, 2),
if (cache.length != channels.length) await cache.clear(); child: InputChip(
avatar: Icon(chiplet.icon()),
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel); label: Text(chiplet.label),
onDeleted: () => _onRemFilter(chiplet),
onPressed: () {/* TODO idk what to do here ? */},
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
);
} }
Future<void> _addToMessageCache(List<Message> newItems) async { void _onAddFilter(List<MessageFilterChipletType> remTypeList, List<MessageFilterChiplet> chiplets) {
final cache = Hive.box<Message>('scn-message-cache'); setState(() {
final remTypes = remTypeList.toSet();
for (var msg in newItems) await cache.put(msg.messageID, msg); _filterChiplets = _filterChiplets.where((element) => !remTypes.contains(element.type)).toList() + chiplets;
// delete all but the newest 128 messages _pagingController.refresh();
});
}
if (cache.length < _pageSize) return; void _onRemFilter(MessageFilterChiplet chiplet) {
setState(() {
_filterChiplets.remove(chiplet);
final allValues = cache.values.toList(); _pagingController.refresh();
});
}
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); void _onMessageReceivedViaNotification(SCNMessage msg) {
setState(() {
_pagingController.itemList = [msg] + (_pagingController.itemList ?? []);
});
}
for (var val in allValues.sublist(_pageSize)) { MessageFilter _getFilter() {
await cache.delete(val.messageID); var filter = MessageFilter();
var chipletsChannel = _filterChiplets.where((p) => p.type == MessageFilterChipletType.channel).toList();
if (chipletsChannel.isNotEmpty) {
filter.channelIDs = chipletsChannel.map((p) => p.value as String).toList();
} }
var chipletsSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.search).toList();
if (chipletsSearch.isNotEmpty) {
filter.searchFilter = chipletsSearch.map((p) => p.value as String).toList();
}
var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList();
if (chipletsKeyTokens.isNotEmpty) {
filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList();
}
var chipletPriority = _filterChiplets.where((p) => p.type == MessageFilterChipletType.priority).toList();
if (chipletPriority.isNotEmpty) {
filter.priority = chipletPriority.map((p) => p.value as int).toList();
}
var chipletSender = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sender).toList();
if (chipletSender.isNotEmpty) {
filter.senderNames = chipletSender.map((p) => p.value as String).toList();
}
return filter;
} }
} }

View File

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -18,7 +18,7 @@ class MessageListItem extends StatelessWidget {
super.key, super.key,
}); });
final Message message; final SCNMessage message;
final Map<String, Channel> allChannels; final Map<String, Channel> allChannels;
final Null Function() onPressed; final Null Function() onPressed;
@ -176,11 +176,11 @@ class MessageListItem extends StatelessWidget {
return v; return v;
} }
String resolveChannelName(Message message) { String resolveChannelName(SCNMessage message) {
return allChannels[message.channelID]?.displayName ?? message.channelInternalName; return allChannels[message.channelID]?.displayName ?? message.channelInternalName;
} }
bool showChannel(Message message) { bool showChannel(SCNMessage message) {
return message.channelInternalName != 'main'; return message.channelInternalName != 'main';
} }
} }

View File

@ -8,36 +8,49 @@ import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
class MessageViewPage extends StatefulWidget { class MessageViewPage extends StatefulWidget {
const MessageViewPage({super.key, required this.message}); const MessageViewPage({
super.key,
required this.messageID,
required this.preloadedData,
});
final Message message; // Potentially trimmed final String messageID; // Potentially trimmed
final (SCNMessage,)? preloadedData; // Message is potentially trimmed, whole object is potentially null
@override @override
State<MessageViewPage> createState() => _MessageViewPageState(); State<MessageViewPage> createState() => _MessageViewPageState();
} }
class _MessageViewPageState extends State<MessageViewPage> { class _MessageViewPageState extends State<MessageViewPage> {
late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture; late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null; (SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
bool _monospaceMode = false; bool _monospaceMode = false;
SCNMessage? message = null;
@override @override
void initState() { void initState() {
if (widget.preloadedData != null) {
message = widget.preloadedData!.$1;
}
mainFuture = fetchData(); mainFuture = fetchData();
super.initState(); super.initState();
} }
Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async { Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
try { try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
@ -45,7 +58,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
final msg = await APIClient.getMessage(acc, widget.message.messageID); final msg = await APIClient.getMessage(acc, widget.messageID);
final fut_chn = APIClient.getChannelPreview(acc, msg.channelID); final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID); final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
@ -79,7 +92,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
showSearch: false, showSearch: false,
showShare: true, showShare: true,
onShare: _share, onShare: _share,
child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>( child: FutureBuilder<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>(
future: mainFuture, future: mainFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
@ -87,8 +100,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
return _buildMessageView(context, msg, chn, tok, usr); return _buildMessageView(context, msg, chn, tok, usr);
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
return Center(child: Text('${snapshot.error}')); //TODO nice error page return Center(child: Text('${snapshot.error}')); //TODO nice error page
} else if (!widget.message.trimmed) { } else if (message != null && !this.message!.trimmed) {
return _buildMessageView(context, widget.message, null, null, null); return _buildMessageView(context, this.message!, null, null, null);
} else { } else {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@ -98,7 +111,9 @@ class _MessageViewPageState extends State<MessageViewPage> {
} }
void _share() async { void _share() async {
var msg = widget.message; if (this.message == null) return;
var msg = this.message!;
if (mainFutureSnapshot != null) { if (mainFutureSnapshot != null) {
(msg, _, _, _) = mainFutureSnapshot!; (msg, _, _, _) = mainFutureSnapshot!;
} }
@ -118,7 +133,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
} }
} }
Widget _buildMessageView(BuildContext context, Message message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) { Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID); final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
return SingleChildScrollView( return SingleChildScrollView(
@ -131,12 +146,58 @@ class _MessageViewPageState extends State<MessageViewPage> {
SizedBox(height: 8), SizedBox(height: 8),
if (message.content != null) ..._buildMessageContent(context, message), if (message.content != null) ..._buildMessageContent(context, message),
SizedBox(height: 8), SizedBox(height: 8),
if (message.senderName != null) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}), if (message.senderName != null)
_buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}), UI.metaCard(
_buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null), context: context,
_buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}), icon: FontAwesomeIcons.solidSignature,
_buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null), title: 'Sender',
_buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO values: [message.senderName!],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidGearCode,
title: 'KeyToken',
values: [message.usedKeyID, token?.name ?? '...'],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'MessageID',
values: [message.messageID, message.userMessageID ?? ''],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channel',
values: [message.channelID, channel?.displayName ?? message.channelInternalName],
mainAction: (channel != null)
? () {
Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null));
}
: null,
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidTimer,
title: 'Timestamp',
values: [message.timestamp],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'User',
values: [user?.userID ?? '...', user?.username ?? ''],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidBolt,
title: 'Priority',
values: [_prettyPrintPriority(message.priority)],
mainAction: () => {/*TODO*/},
),
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]),
], ],
), ),
@ -144,11 +205,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
); );
} }
String _resolveChannelName(ChannelPreview? channel, Message message) { String _resolveChannelName(ChannelPreview? channel, SCNMessage message) {
return channel?.displayName ?? message.channelInternalName; return channel?.displayName ?? message.channelInternalName;
} }
List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) { List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) {
return [ return [
Row( Row(
children: [ children: [
@ -167,7 +228,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
]; ];
} }
List<Widget> _buildMessageContent(BuildContext context, Message message) { List<Widget> _buildMessageContent(BuildContext context, SCNMessage message) {
return [ return [
Row( Row(
children: [ children: [
@ -178,6 +239,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
onPressed: () { onPressed: () {
Clipboard.setData(new ClipboardData(text: message.content ?? '')); Clipboard.setData(new ClipboardData(text: message.content ?? ''));
Toaster.info("Clipboard", 'Copied text to Clipboard'); Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${message.content}\n================= [/CLIPBOARD] =================');
}, },
icon: FontAwesomeIcons.copy, icon: FontAwesomeIcons.copy,
), ),
@ -213,43 +275,20 @@ class _MessageViewPageState extends State<MessageViewPage> {
]; ];
} }
Widget _buildMetaCard(BuildContext context, IconData icn, String title, List<String> values, void Function()? action) { String _preformatTitle(SCNMessage message) {
final container = UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
FaIcon(icn, size: 18),
SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
],
),
],
),
);
if (action == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: container,
);
} else {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: action,
child: container,
),
);
}
}
String _preformatTitle(Message message) {
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
} }
String _prettyPrintPriority(int priority) {
switch (priority) {
case 0:
return 'Low (0)';
case 1:
return 'Normal (1)';
case 2:
return 'High (2)';
default:
return 'Unknown ($priority)';
}
}
} }

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class AppSettings extends ChangeNotifier {
bool groupNotifications = true;
int messagePageSize = 128;
bool showDebugButton = true;
bool backgroundRefreshMessageListOnPop = false;
bool alwaysBackgroundRefreshMessageListOnLifecycleResume = true;
static AppSettings? _singleton = AppSettings._internal();
factory AppSettings() {
return _singleton ?? (_singleton = AppSettings._internal());
}
AppSettings._internal() {
load();
}
void clear() {
//TODO
notifyListeners();
}
void load() {
//TODO
notifyListeners();
}
Future<void> save() async {
//TODO
}
}

View File

@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/api/api_exception.dart'; import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/token_source.dart'; import 'package:simplecloudnotifier/state/token_source.dart';
@ -12,9 +15,9 @@ class AppAuth extends ChangeNotifier implements TokenSource {
String? _tokenAdmin; String? _tokenAdmin;
String? _tokenSend; String? _tokenSend;
User? _user; (User, DateTime)? _user;
Client? _client;
DateTime? _clientQueryTime; (Client, DateTime)? _client;
String? get userID => _userID; String? get userID => _userID;
String? get tokenAdmin => _tokenAdmin; String? get tokenAdmin => _tokenAdmin;
@ -35,17 +38,21 @@ class AppAuth extends ChangeNotifier implements TokenSource {
} }
void set(User user, Client client, String tokenAdmin, String tokenSend) { void set(User user, Client client, String tokenAdmin, String tokenSend) {
_client = client; _client = (client, DateTime.now());
_user = user;
_user = (user, DateTime.now());
_userID = user.userID; _userID = user.userID;
_clientID = client.clientID; _clientID = client.clientID;
_tokenAdmin = tokenAdmin; _tokenAdmin = tokenAdmin;
_tokenSend = tokenSend; _tokenSend = tokenSend;
notifyListeners(); notifyListeners();
} }
void setClientAndClientID(Client client) { void setClientAndClientID(Client client) {
_client = client; _client = (client, DateTime.now());
_clientID = client.clientID; _clientID = client.clientID;
notifyListeners(); notifyListeners();
} }
@ -83,6 +90,33 @@ class AppAuth extends ChangeNotifier implements TokenSource {
_client = null; _client = null;
_user = null; _user = null;
final userjson = Globals().sharedPrefs.getString('auth.user.obj');
final userqdate = Globals().sharedPrefs.getString('auth.user.qdate');
final clientjson = Globals().sharedPrefs.getString('auth.client.obj');
final clientqdate = Globals().sharedPrefs.getString('auth.client.qdate');
if (userjson != null && userqdate != null) {
try {
final ts = DateTime.parse(userqdate);
final obj = User.fromJson(jsonDecode(userjson) as Map<String, dynamic>);
_user = (obj, ts);
} catch (exc, trace) {
ApplicationLog.error('failed to parse user object from shared-prefs (auth.user.obj): ' + exc.toString(), additional: 'Data:\n${userjson}\nQDate:\n${userqdate}', trace: trace);
_user = null;
}
}
if (clientjson != null && clientqdate != null) {
try {
final ts = DateTime.parse(clientqdate);
final obj = Client.fromJson(jsonDecode(clientjson) as Map<String, dynamic>);
_client = (obj, ts);
} catch (exc, trace) {
ApplicationLog.error('failed to parse user object from shared-prefs (auth.client.obj): ' + exc.toString(), additional: 'Data:\n${clientjson}\nQDate:\n${clientqdate}', trace: trace);
_client = null;
}
}
notifyListeners(); notifyListeners();
} }
@ -94,6 +128,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.remove('auth.tokensend'); await Globals().sharedPrefs.remove('auth.tokensend');
await Globals().sharedPrefs.setString('auth.cdate', ""); await Globals().sharedPrefs.setString('auth.cdate', "");
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
await Globals().sharedPrefs.remove('auth.user.obj');
await Globals().sharedPrefs.remove('auth.user.qdate');
await Globals().sharedPrefs.remove('auth.client.obj');
await Globals().sharedPrefs.remove('auth.client.qdate');
} else { } else {
await Globals().sharedPrefs.setString('auth.userid', _userID!); await Globals().sharedPrefs.setString('auth.userid', _userID!);
await Globals().sharedPrefs.setString('auth.clientid', _clientID!); await Globals().sharedPrefs.setString('auth.clientid', _clientID!);
@ -101,14 +139,34 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!); await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!);
if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String()); if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String());
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
if (_user != null) {
await Globals().sharedPrefs.setString('auth.user.obj', jsonEncode(_user!.$1.toJson()));
await Globals().sharedPrefs.setString('auth.user.qdate', _user!.$2.toIso8601String());
} else {
await Globals().sharedPrefs.remove('auth.user.obj');
await Globals().sharedPrefs.remove('auth.user.qdate');
}
if (_client != null) {
await Globals().sharedPrefs.setString('auth.client.obj', jsonEncode(_client!.$1.toJson()));
await Globals().sharedPrefs.setString('auth.client.qdate', _client!.$2.toIso8601String());
} else {
await Globals().sharedPrefs.remove('auth.client.obj');
await Globals().sharedPrefs.remove('auth.client.qdate');
}
} }
Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
} }
Future<User> loadUser({bool force = false}) async { Future<User> loadUser({bool force = false, Duration? forceIfOlder = null}) async {
if (!force && _user != null && _user!.userID == _userID) { if (forceIfOlder != null && _user != null && _user!.$2.difference(DateTime.now()) > forceIfOlder) {
return _user!; force = true;
}
if (!force && _user != null && _user!.$1.userID == _userID) {
return _user!.$1;
} }
if (_userID == null || _tokenAdmin == null) { if (_userID == null || _tokenAdmin == null) {
@ -117,20 +175,24 @@ class AppAuth extends ChangeNotifier implements TokenSource {
final user = await APIClient.getUser(this, _userID!); final user = await APIClient.getUser(this, _userID!);
_user = user; _user = (user, DateTime.now());
await save(); await save();
return user; return user;
} }
User? getUserOrNull() {
return _user?.$1;
}
Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async { Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async {
if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) { if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) {
force = true; force = true;
} }
if (!force && _client != null && _client!.clientID == _clientID) { if (!force && _client != null && _client!.$1.clientID == _clientID) {
return _client!; return _client!.$1;
} }
if (_clientID == null || _tokenAdmin == null) { if (_clientID == null || _tokenAdmin == null) {
@ -140,7 +202,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
try { try {
final client = await APIClient.getClient(this, _clientID!); final client = await APIClient.getClient(this, _clientID!);
_client = client; _client = (client, DateTime.now());
await save(); await save();
@ -154,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
} }
} }
Client? getClientOrNull() {
return _client?.$1;
}
@override @override
String getToken() { String getToken() {
return _tokenAdmin!; return _tokenAdmin!;

View File

@ -12,9 +12,18 @@ class AppBarState extends ChangeNotifier {
bool _loadingIndeterminate = false; bool _loadingIndeterminate = false;
bool get loadingIndeterminate => _loadingIndeterminate; bool get loadingIndeterminate => _loadingIndeterminate;
bool _showSearchField = false;
bool get showSearchField => _showSearchField;
void setLoadingIndeterminate(bool v) { void setLoadingIndeterminate(bool v) {
if (_loadingIndeterminate == v) return; if (_loadingIndeterminate == v) return;
_loadingIndeterminate = v; _loadingIndeterminate = v;
notifyListeners(); notifyListeners();
} }
void setShowSearchField(bool v) {
if (_showSearchField == v) return;
_showSearchField = v;
notifyListeners();
}
} }

View File

@ -0,0 +1,53 @@
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
class AppEvents {
static AppEvents? _singleton = AppEvents._internal();
factory AppEvents() {
return _singleton ?? (_singleton = AppEvents._internal());
}
AppEvents._internal() {}
// --------------------------------------------------------------------------
List<void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>)> _filterListeners = [];
void subscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.add(listener);
}
void unsubscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.remove(listener);
}
void notifyFilterListeners(List<MessageFilterChipletType> types, List<MessageFilterChiplet> query) {
ApplicationLog.debug('[AppEvents] onFilter: [${types.join(" ; ")}], [${query.map((e) => e.label).join('|')}]');
for (var listener in _filterListeners) {
listener(types, query);
}
}
// --------------------------------------------------------------------------
List<void Function(SCNMessage)> _messageReceivedListeners = [];
void subscribeMessageReceivedListener(void Function(SCNMessage) listener) {
_messageReceivedListeners.add(listener);
}
void unsubscribeMessageReceivedListener(void Function(SCNMessage) listener) {
_messageReceivedListeners.remove(listener);
}
void notifyMessageReceivedListeners(SCNMessage msg) {
ApplicationLog.debug('[AppEvents] onMessageReceived: ${msg.messageID}');
for (var listener in _messageReceivedListeners) {
listener(msg);
}
}
}

View File

@ -10,6 +10,7 @@ class ApplicationLog {
static void debug(String message, {String? additional, StackTrace? trace}) { static void debug(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}'); (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),
@ -23,6 +24,7 @@ class ApplicationLog {
static void info(String message, {String? additional, StackTrace? trace}) { static void info(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}'); (additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),
@ -36,6 +38,7 @@ class ApplicationLog {
static void warn(String message, {String? additional, StackTrace? trace}) { static void warn(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}'); (additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),
@ -49,6 +52,7 @@ class ApplicationLog {
static void error(String message, {String? additional, StackTrace? trace}) { static void error(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}'); (additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),
@ -62,6 +66,7 @@ class ApplicationLog {
static void fatal(String message, {String? additional, StackTrace? trace}) { static void fatal(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}'); (additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),

View File

@ -32,8 +32,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
final String? messageType; final String? messageType;
@HiveField(8) @HiveField(8)
final bool mutableContent; final bool mutableContent;
@HiveField(9)
final RemoteNotification? notification;
@HiveField(10) @HiveField(10)
final DateTime? sentTime; final DateTime? sentTime;
@HiveField(11) @HiveField(11)
@ -54,7 +52,7 @@ class FBMessage extends HiveObject implements FieldDebuggable {
@HiveField(25) @HiveField(25)
final String? notificationAndroidLink; final String? notificationAndroidLink;
@HiveField(26) @HiveField(26)
final AndroidNotificationPriority? notificationAndroidPriority; final String? notificationAndroidPriority;
@HiveField(27) @HiveField(27)
final String? notificationAndroidSmallIcon; final String? notificationAndroidSmallIcon;
@HiveField(28) @HiveField(28)
@ -62,14 +60,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
@HiveField(29) @HiveField(29)
final String? notificationAndroidTicker; final String? notificationAndroidTicker;
@HiveField(30) @HiveField(30)
final AndroidNotificationVisibility? notificationAndroidVisibility; final String? notificationAndroidVisibility;
@HiveField(31) @HiveField(31)
final String? notificationAndroidTag; final String? notificationAndroidTag;
@HiveField(40) @HiveField(40)
final String? notificationAppleBadge; final String? notificationAppleBadge;
@HiveField(41) @HiveField(41)
final AppleNotificationSound? notificationAppleSound; final String? notificationAppleSound;
@HiveField(42) @HiveField(42)
final String? notificationAppleImageUrl; final String? notificationAppleImageUrl;
@HiveField(43) @HiveField(43)
@ -109,7 +107,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
required this.messageId, required this.messageId,
required this.messageType, required this.messageType,
required this.mutableContent, required this.mutableContent,
required this.notification,
required this.sentTime, required this.sentTime,
required this.threadId, required this.threadId,
required this.ttl, required this.ttl,
@ -152,7 +149,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
this.messageId = rmsg.messageId, this.messageId = rmsg.messageId,
this.messageType = rmsg.messageType, this.messageType = rmsg.messageType,
this.mutableContent = rmsg.mutableContent, this.mutableContent = rmsg.mutableContent,
this.notification = rmsg.notification,
this.sentTime = rmsg.sentTime, this.sentTime = rmsg.sentTime,
this.threadId = rmsg.threadId, this.threadId = rmsg.threadId,
this.ttl = rmsg.ttl, this.ttl = rmsg.ttl,
@ -162,14 +158,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
this.notificationAndroidCount = rmsg.notification?.android?.count, this.notificationAndroidCount = rmsg.notification?.android?.count,
this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl, this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl,
this.notificationAndroidLink = rmsg.notification?.android?.link, this.notificationAndroidLink = rmsg.notification?.android?.link,
this.notificationAndroidPriority = rmsg.notification?.android?.priority, this.notificationAndroidPriority = rmsg.notification?.android?.priority.toString(),
this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon, this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon,
this.notificationAndroidSound = rmsg.notification?.android?.sound, this.notificationAndroidSound = rmsg.notification?.android?.sound,
this.notificationAndroidTicker = rmsg.notification?.android?.ticker, this.notificationAndroidTicker = rmsg.notification?.android?.ticker,
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility, this.notificationAndroidVisibility = rmsg.notification?.android?.visibility.toString(),
this.notificationAndroidTag = rmsg.notification?.android?.tag, this.notificationAndroidTag = rmsg.notification?.android?.tag,
this.notificationAppleBadge = rmsg.notification?.apple?.badge, this.notificationAppleBadge = rmsg.notification?.apple?.badge,
this.notificationAppleSound = rmsg.notification?.apple?.sound, this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(),
this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl, this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl,
this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle, this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle,
this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs, this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs,
@ -195,12 +191,11 @@ class FBMessage extends HiveObject implements FieldDebuggable {
('category', this.category ?? ''), ('category', this.category ?? ''),
('collapseKey', this.collapseKey ?? ''), ('collapseKey', this.collapseKey ?? ''),
('contentAvailable', this.contentAvailable.toString()), ('contentAvailable', this.contentAvailable.toString()),
('data', this.data.toString()), ('data', this.data.entries.map((e) => '${e.key} := ${e.value}').join('\n')),
('from', this.from ?? ''), ('from', this.from ?? ''),
('messageId', this.messageId ?? ''), ('messageId', this.messageId ?? ''),
('messageType', this.messageType ?? ''), ('messageType', this.messageType ?? ''),
('mutableContent', this.mutableContent.toString()), ('mutableContent', this.mutableContent.toString()),
('notification', this.notification?.toString() ?? ''),
('sentTime', this.sentTime?.toString() ?? ''), ('sentTime', this.sentTime?.toString() ?? ''),
('threadId', this.threadId ?? ''), ('threadId', this.threadId ?? ''),
('ttl', this.ttl?.toString() ?? ''), ('ttl', this.ttl?.toString() ?? ''),

View File

@ -26,7 +26,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
messageId: fields[6] as String?, messageId: fields[6] as String?,
messageType: fields[7] as String?, messageType: fields[7] as String?,
mutableContent: fields[8] as bool, mutableContent: fields[8] as bool,
notification: fields[9] as RemoteNotification?,
sentTime: fields[10] as DateTime?, sentTime: fields[10] as DateTime?,
threadId: fields[11] as String?, threadId: fields[11] as String?,
ttl: fields[12] as int?, ttl: fields[12] as int?,
@ -36,15 +35,14 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
notificationAndroidCount: fields[23] as int?, notificationAndroidCount: fields[23] as int?,
notificationAndroidImageUrl: fields[24] as String?, notificationAndroidImageUrl: fields[24] as String?,
notificationAndroidLink: fields[25] as String?, notificationAndroidLink: fields[25] as String?,
notificationAndroidPriority: fields[26] as AndroidNotificationPriority?, notificationAndroidPriority: fields[26] as String?,
notificationAndroidSmallIcon: fields[27] as String?, notificationAndroidSmallIcon: fields[27] as String?,
notificationAndroidSound: fields[28] as String?, notificationAndroidSound: fields[28] as String?,
notificationAndroidTicker: fields[29] as String?, notificationAndroidTicker: fields[29] as String?,
notificationAndroidVisibility: notificationAndroidVisibility: fields[30] as String?,
fields[30] as AndroidNotificationVisibility?,
notificationAndroidTag: fields[31] as String?, notificationAndroidTag: fields[31] as String?,
notificationAppleBadge: fields[40] as String?, notificationAppleBadge: fields[40] as String?,
notificationAppleSound: fields[41] as AppleNotificationSound?, notificationAppleSound: fields[41] as String?,
notificationAppleImageUrl: fields[42] as String?, notificationAppleImageUrl: fields[42] as String?,
notificationAppleSubtitle: fields[43] as String?, notificationAppleSubtitle: fields[43] as String?,
notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(), notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(),
@ -64,7 +62,7 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
@override @override
void write(BinaryWriter writer, FBMessage obj) { void write(BinaryWriter writer, FBMessage obj) {
writer writer
..writeByte(40) ..writeByte(39)
..writeByte(0) ..writeByte(0)
..write(obj.senderId) ..write(obj.senderId)
..writeByte(1) ..writeByte(1)
@ -83,8 +81,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
..write(obj.messageType) ..write(obj.messageType)
..writeByte(8) ..writeByte(8)
..write(obj.mutableContent) ..write(obj.mutableContent)
..writeByte(9)
..write(obj.notification)
..writeByte(10) ..writeByte(10)
..write(obj.sentTime) ..write(obj.sentTime)
..writeByte(11) ..writeByte(11)

View File

@ -13,6 +13,8 @@ class Globals {
Globals._internal(); Globals._internal();
bool _initialized = false;
String appName = ''; String appName = '';
String packageName = ''; String packageName = '';
String version = ''; String version = '';
@ -24,7 +26,11 @@ class Globals {
late SharedPreferences sharedPrefs; late SharedPreferences sharedPrefs;
bool get isInitialized => _initialized;
Future<void> init() async { Future<void> init() async {
if (_initialized) return;
PackageInfo packageInfo = await PackageInfo.fromPlatform(); PackageInfo packageInfo = await PackageInfo.fromPlatform();
this.appName = packageInfo.appName; this.appName = packageInfo.appName;
@ -54,6 +60,8 @@ class Globals {
} }
this.sharedPrefs = await SharedPreferences.getInstance(); this.sharedPrefs = await SharedPreferences.getInstance();
this._initialized = true;
} }
String? getPrefFCMToken() { String? getPrefFCMToken() {

View File

@ -0,0 +1,60 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
class SCNDataCache {
SCNDataCache._internal();
static final SCNDataCache _instance = SCNDataCache._internal();
factory SCNDataCache() => _instance;
Future<void> addToMessageCache(List<SCNMessage> newItems) async {
final cfg = AppSettings();
final cache = Hive.box<SCNMessage>('scn-message-cache');
for (var msg in newItems) await cache.put(msg.messageID, msg);
// delete all but the newest 128 messages
if (cache.length < cfg.messagePageSize) return;
final allValues = cache.values.toList();
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
for (var val in allValues.sublist(cfg.messagePageSize)) {
await cache.delete(val.messageID);
}
}
Future<void> setChannelCache(List<ChannelWithSubscription> channels) async {
final cache = Hive.box<Channel>('scn-channel-cache');
if (cache.length != channels.length) await cache.clear();
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel);
}
bool hasMessagesAndChannels() {
final chnCache = Hive.box<Channel>('scn-channel-cache');
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
return chnCache.isNotEmpty && msgCache.isNotEmpty;
}
Map<String, Channel> getChannelMap() {
final chnCache = Hive.box<Channel>('scn-channel-cache');
return <String, Channel>{for (var v in chnCache.values) v.channelID: v};
}
List<SCNMessage> getMessagesSorted() {
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
final cacheMessages = msgCache.values.toList();
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
return cacheMessages;
}
}

View File

@ -1,18 +1,26 @@
// This class is useful togther with FutureBuilder // This class is useful togther with FutureBuilder
// Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting // Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting
// Whit way we can set the ImmediateFuture.value directly and circumvent that. // This way we can set the ImmediateFuture.value directly and circumvent that.
class ImmediateFuture<T> { class ImmediateFuture<T> {
final Future<T> future; final Future<T> future;
final T? value; final T? value;
T? _futureValue = null;
ImmediateFuture(this.future, this.value); ImmediateFuture(this.future, this.value);
ImmediateFuture.ofFuture(Future<T> v) ImmediateFuture.ofFuture(Future<T> v)
: future = v, : future = v,
value = null; value = null {
future.then((v) => _futureValue = v);
}
ImmediateFuture.ofValue(T v) ImmediateFuture.ofValue(T v)
: future = Future.value(v), : future = Future.value(v),
value = v; value = v;
T? get() {
return value ?? _futureValue;
}
} }

View File

@ -8,15 +8,21 @@ class Navi {
static void push<T extends Widget>(BuildContext context, T Function() builder) { static void push<T extends Widget>(BuildContext context, T Function() builder) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false); Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder())); Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
} }
static void popToRoot(BuildContext context) { static void popToRoot(BuildContext context) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false); Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.popUntil(context, (route) => route.isFirst); Navigator.popUntil(context, (route) => route.isFirst);
} }
static void popDialog(BuildContext dialogContext) {
Navigator.pop(dialogContext);
}
} }
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> { class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPush(route, previousRoute); super.didPush(route, previousRoute);
if (route is PageRoute) { if (route is PageRoute) {
AppBarState().setLoadingIndeterminate(false); AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPush()'); print('[SCNRouteObserver] .didPush()');
} }
@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute); super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute is PageRoute) { if (newRoute is PageRoute) {
AppBarState().setLoadingIndeterminate(false); AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didReplace()'); print('[SCNRouteObserver] .didReplace()');
} }
@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPop(route, previousRoute); super.didPop(route, previousRoute);
if (previousRoute is PageRoute && route is PageRoute) { if (previousRoute is PageRoute && route is PageRoute) {
AppBarState().setLoadingIndeterminate(false); AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPop()'); print('[SCNRouteObserver] .didPop()');
} }

View File

@ -0,0 +1,83 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
class Notifier {
static void showLocalNotification(String messageID, String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async {
final nid = Globals().sharedPrefs.getInt('notifier.nextid') ?? 1000;
Globals().sharedPrefs.setInt('notifier.nextid', nid + 7);
final existingSummaryNID = Globals().sharedPrefs.getInt('notifier.summary.$channelID');
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
if (Platform.isAndroid && AppSettings().groupNotifications) {
final activeNotifications = (await flutterLocalNotificationsPlugin.getActiveNotifications()).where((p) => p.groupKey == channelID).toList();
final summaryNotification = activeNotifications.where((p) => p.id == existingSummaryNID).toList();
ApplicationLog.debug('found ${activeNotifications.length} active notifications in this group (${summaryNotification.length} summary notifications for channel ${channelID} with nid [${existingSummaryNID}])');
if (activeNotifications.isNotEmpty && !activeNotifications.any((p) => p.id == existingSummaryNID)) {
// ======== SHOW SUMMARY/GROUPING NOTIFICATION ========
final newSummaryNID = nid + 1;
ApplicationLog.debug('Create new summary notifications for channel ${channelID} with nid [${newSummaryNID}])');
Globals().sharedPrefs.setInt('notifier.summary.$channelID', newSummaryNID);
var payload = '';
if (messageID != '') {
payload = ['@SCN_MESSAGE_SUMMARY', channelID, newSummaryNID].join("\n");
}
await flutterLocalNotificationsPlugin.show(
newSummaryNID,
channelName,
"(multiple notifications)",
NotificationDetails(
android: AndroidNotificationDetails(
channelID,
channelName,
importance: Importance.max,
priority: Priority.high,
groupKey: channelID,
setAsGroupSummary: true,
subText: (channelName == 'main') ? null : channelName,
),
),
payload: payload,
);
}
}
final newMessageNID = nid + 2;
ApplicationLog.debug('Create new local notifications for message in channel ${channelID} with nid [${newMessageNID}])');
var payload = '';
if (messageID != '') {
payload = ['@SCN_MESSAGE', messageID, channelID, newMessageNID].join("\n");
}
// ======== SHOW NOTIFICATION ========
await flutterLocalNotificationsPlugin.show(
newMessageNID,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
channelID,
channelName,
channelDescription: channelDescr,
importance: Importance.max,
priority: Priority.high,
when: timestamp?.millisecondsSinceEpoch,
groupKey: channelID,
subText: (channelName == 'main') ? null : channelName,
),
),
payload: payload,
);
}
}

View File

@ -106,4 +106,49 @@ class UI {
child: child, child: child,
); );
} }
static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List<String> values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) {
final container = UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
FaIcon(icon, size: 18),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
],
),
),
if (iconActions != null) ...[
SizedBox(width: 12),
for (final iconAction in iconActions) ...[
SizedBox(width: 4),
IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2),
],
],
],
),
);
if (mainAction == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: container,
);
} else {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: mainAction,
child: container,
),
);
}
}
} }

View File

@ -8,6 +8,7 @@ import Foundation
import device_info_plus import device_info_plus
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications
import package_info_plus import package_info_plus
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

View File

@ -209,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.6" version: "2.3.6"
dbus:
dependency: transitive
description:
name: dbus
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
device_info_plus: device_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -342,6 +350,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "40e6fbd2da7dcc7ed78432c5cdab1559674b4af035fddbfb2f9a8f9c2112fcef"
url: "https://pub.dev"
source: hosted
version: "17.1.2"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03"
url: "https://pub.dev"
source: hosted
version: "4.0.0+1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "340abf67df238f7f0ef58f4a26d2a83e1ab74c77ab03cd2b2d5018ac64db30b7"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
flutter_staggered_grid_view: flutter_staggered_grid_view:
dependency: transitive dependency: transitive
description: description:
@ -916,6 +948,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.1"
timezone:
dependency: transitive
description:
name: timezone
sha256: a6ccda4a69a442098b602c44e61a1e2b4bf6f5516e875bbf0f427d5df14745d5
url: "https://pub.dev"
source: hosted
version: "0.9.3"
timing: timing:
dependency: transitive dependency: transitive
description: description:

View File

@ -33,6 +33,7 @@ dependencies:
toastification: ^2.0.0 toastification: ^2.0.0
uuid: ^4.4.0 uuid: ^4.4.0
share_plus: ^9.0.0 share_plus: ^9.0.0
flutter_local_notifications: ^17.1.2
dependency_overrides: dependency_overrides:

View File

@ -5,7 +5,7 @@ PORT=9090
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD) NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
HASH=$(shell git rev-parse HEAD) HASH=$(shell git rev-parse HEAD)
TAGS="timetzdata sqlite_fts5 sqlite_foreign_keys" TAGS="timetzdata"
.PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker .PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker

View File

@ -418,6 +418,7 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
Messages []models.Message `json:"messages"` Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"` NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
} }
var u uri var u uri
@ -457,16 +458,16 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}), ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}),
} }
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok) messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
} }
if trimmed { if trimmed {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.Trim() }) res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.Trim() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize})) return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} else { } else {
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize})) return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} }
}) })

View File

@ -39,7 +39,8 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
type query struct { type query struct {
PageSize *int `json:"page_size" form:"page_size"` PageSize *int `json:"page_size" form:"page_size"`
NextPageToken *string `json:"next_page_token" form:"next_page_token"` NextPageToken *string `json:"next_page_token" form:"next_page_token"`
Filter *string `json:"filter" form:"filter"` Search []string `json:"search" form:"search"`
StringSearch []string `json:"string_search" form:"string_search"`
Trimmed *bool `json:"trimmed" form:"trimmed"` Trimmed *bool `json:"trimmed" form:"trimmed"`
Channels []string `json:"channel" form:"channel"` Channels []string `json:"channel" form:"channel"`
ChannelIDs []string `json:"channel_id" form:"channel_id"` ChannelIDs []string `json:"channel_id" form:"channel_id"`
@ -48,11 +49,13 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
TimeAfter *string `json:"after" form:"after"` // RFC3339 TimeAfter *string `json:"after" form:"after"` // RFC3339
Priority []int `json:"priority" form:"priority"` Priority []int `json:"priority" form:"priority"`
KeyTokens []string `json:"used_key" form:"used_key"` KeyTokens []string `json:"used_key" form:"used_key"`
HasSender *bool `json:"has_sender" form:"has_sender"`
} }
type response struct { type response struct {
Messages []models.Message `json:"messages"` Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"` NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
} }
var q query var q query
@ -90,8 +93,12 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
ConfirmedSubscriptionBy: langext.Ptr(userid), ConfirmedSubscriptionBy: langext.Ptr(userid),
} }
if q.Filter != nil && strings.TrimSpace(*q.Filter) != "" { if len(q.Search) != 0 {
filter.SearchString = langext.Ptr([]string{strings.TrimSpace(*q.Filter)}) filter.SearchStringFTS = langext.Ptr(langext.ArrMap(q.Search, func(v string) string { return strings.TrimSpace(v) }))
}
if len(q.StringSearch) != 0 {
filter.SearchStringPlain = langext.Ptr(langext.ArrMap(q.StringSearch, func(v string) string { return strings.TrimSpace(v) }))
} }
if len(q.Channels) != 0 { if len(q.Channels) != 0 {
@ -114,6 +121,10 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
filter.SenderNameCS = langext.Ptr(q.Senders) filter.SenderNameCS = langext.Ptr(q.Senders)
} }
if q.HasSender != nil {
filter.HasSenderName = langext.Ptr(*q.HasSender)
}
if q.TimeBefore != nil { if q.TimeBefore != nil {
t0, err := time.Parse(time.RFC3339, *q.TimeBefore) t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
if err != nil { if err != nil {
@ -146,17 +157,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
filter.UsedKeyID = &tids filter.UsedKeyID = &tids
} }
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok) messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
} }
if trimmed { if trimmed {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal().Trim() }) res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal().Trim() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize})) return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} else { } else {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal() }) res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize})) return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} }
}) })
} }

View File

@ -0,0 +1,102 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"net/http"
)
// ListUserSenderNames swaggerdoc
//
// @Summary List sender-names (of allthe messages of this user)
// @ID api-usersendernames-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} handler.ListUserKeys.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys [GET]
func (h APIHandler) ListUserSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type response struct {
SenderNames []models.SenderNameStatistics `json:"sender_names"`
}
var u uri
ctx, g, errResp := pctx.URI(&u).Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
names, err := h.database.ListSenderNames(ctx, u.UserID, false)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
})
}
// ListSenderNames swaggerdoc
//
// @Summary List sender-names (of all messages this user can view, eitehr own or foreign-subscribed)
// @ID api-sendernames-list
// @Tags API-v2
//
// @Success 200 {object} handler.ListSenderNames.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/sender-names [GET]
func (h APIHandler) ListSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
SenderNames []models.SenderNameStatistics `json:"sender_names"`
}
ctx, g, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionAny(); permResp != nil {
return *permResp
}
userID := *ctx.GetPermissionUserID()
if permResp := ctx.CheckPermissionUserRead(userID); permResp != nil {
return *permResp
}
names, err := h.database.ListSenderNames(ctx, userID, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
})
}

View File

@ -80,7 +80,7 @@ func (h APIHandler) CreateUser(pctx ginext.PreContext) ginext.HTTPResponse {
sendKey := h.app.GenerateRandomAuthKey() sendKey := h.app.GenerateRandomAuthKey()
adminKey := h.app.GenerateRandomAuthKey() adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, b.FCMToken) err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
} }

View File

@ -9,7 +9,6 @@ import (
"bytes" "bytes"
"errors" "errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3"
"gogs.mikescher.com/BlackForestBytes/goext/ginext" "gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext" "gogs.mikescher.com/BlackForestBytes/goext/timeext"
@ -91,10 +90,9 @@ func (h CommonHandler) Ping(pctx ginext.PreContext) ginext.HTTPResponse {
// @Router /api/db-test [post] // @Router /api/db-test [post]
func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse { func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct { type response struct {
Success bool `json:"success"` Success bool `json:"success"`
LibVersion string `json:"libVersion"` LibVersion string `json:"libVersion"`
LibVersionNumber int `json:"libVersionNumber"` SourceID string `json:"sourceID"`
SourceID string `json:"sourceID"`
} }
ctx, g, errResp := pctx.Start() ctx, g, errResp := pctx.Start()
@ -105,18 +103,20 @@ func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
libVersion, libVersionNumber, sourceID := sqlite3.Version() versionStr, sourceID, err := h.app.Database.Primary.Version(ctx)
if err != nil {
return ginresp.InternalError(err)
}
err := h.app.Database.Ping(ctx) err = h.app.Database.Ping(ctx)
if err != nil { if err != nil {
return ginresp.InternalError(err) return ginresp.InternalError(err)
} }
return ginext.JSON(http.StatusOK, response{ return ginext.JSON(http.StatusOK, response{
Success: true, Success: true,
LibVersion: libVersion, LibVersion: versionStr,
LibVersionNumber: libVersionNumber, SourceID: sourceID,
SourceID: sourceID,
}) })
}) })
@ -145,12 +145,6 @@ func (h CommonHandler) Health(pctx ginext.PreContext) ginext.HTTPResponse {
return h.app.DoRequest(ctx, g, models.TLockReadWrite, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { return h.app.DoRequest(ctx, g, models.TLockReadWrite, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
_, libVersionNumber, _ := sqlite3.Version()
if libVersionNumber < 3039000 {
return ginresp.InternalError(errors.New("sqlite version too low"))
}
tctx := simplectx.CreateSimpleContext(ctx, nil) tctx := simplectx.CreateSimpleContext(ctx, nil)
err := h.app.Database.Ping(tctx) err := h.app.Database.Ping(tctx)

View File

@ -189,7 +189,7 @@ func (h CompatHandler) Register(pctx ginext.PreContext) ginext.HTTPResponse {
adminKey := h.app.GenerateRandomAuthKey() adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, *data.FCMToken) err := h.database.DeleteClientsByFCM(ctx, *data.FCMToken)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens") return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
} }
@ -538,7 +538,7 @@ func (h CompatHandler) Requery(pctx ginext.PreContext) ginext.HTTPResponse {
CompatAcknowledged: langext.Ptr(false), CompatAcknowledged: langext.Ptr(false),
} }
msgs, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start()) msgs, _, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }

View File

@ -152,10 +152,14 @@ func (r *Router) Init(e *ginext.GinWrapper) error {
apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription) apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription)
apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription) apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription)
apiv2.GET("/users/:uid/sender-names").Handle(r.apiHandler.ListUserSenderNames)
apiv2.GET("/messages").Handle(r.apiHandler.ListMessages) apiv2.GET("/messages").Handle(r.apiHandler.ListMessages)
apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage) apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage)
apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage) apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage)
apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames)
apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview) apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview)
apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview) apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview) apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)

View File

@ -10,6 +10,7 @@ type DatabaseImpl interface {
Migrate(ctx context.Context) error Migrate(ctx context.Context) error
Ping(ctx context.Context) error Ping(ctx context.Context) error
Version(ctx context.Context) (string, string, error)
BeginTx(ctx context.Context) (sq.Tx, error) BeginTx(ctx context.Context) (sq.Tx, error)
Stop(ctx context.Context) error Stop(ctx context.Context) error

View File

@ -13,15 +13,21 @@ import (
"github.com/glebarez/go-sqlite" "github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq" "gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time" "time"
) )
type Database struct { type Database struct {
db sq.DB db sq.DB
pp *dbtools.DBPreprocessor pp *dbtools.DBPreprocessor
wal bool wal bool
name string
schemaVersion int
schema map[int]schema.Def
} }
func NewLogsDatabase(cfg server.Config) (*Database, error) { func NewLogsDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewLogsDatabase(cfg server.Config) (*Database, error) {
qqdb.AddListener(pp) qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"} scndb := &Database{
db: qqdb,
pp: pp,
wal: conf.Journal == "WAL",
schemaVersion: schema.LogsSchemaVersion,
schema: schema.LogsSchema,
name: "logs",
}
return scndb, nil return scndb, nil
} }
@ -99,52 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err return err
} }
if currschema == 0 { if currschema == db.schemaVersion {
schemastr := schema.LogsSchema[schema.LogsSchemaVersion].SQL log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
schemahash := schema.LogsSchema[schema.LogsSchemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.LogsSchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.LogsSchemaVersion
} }
if currschema == 1 { for currschema < db.schemaVersion {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash") if currschema == 0 {
if err != nil { log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.LogsSchema[currschema].Hash { schemastr := db.schema[db.schemaVersion].SQL
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (logs db)") schemahash := db.schema[db.schemaVersion].Hash
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (logs db)")
log.Debug().Str("schemaHashAsset", schema.LogsSchema[currschema].Hash).Msg("Schema (logs db)") _, err = tx.Exec(tctx, schemastr, sq.PP{})
return errors.New("database schema does not match (logs db)") if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = db.schemaVersion
} else { } else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (logs db)") log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
err = db.migrateSingle(tctx, tx, currschema, currschema+1)
if err != nil {
return err
}
currschema = currschema + 1
} }
} }
if currschema != schema.LogsSchemaVersion { if currschema != db.schemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema)) return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
} }
@ -164,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
return nil return nil
} }
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
if schemaFrom == schemaTo-1 {
migSQL := db.schema[schemaTo].MigScript
if migSQL == "" {
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
}
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
}
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
_, err = tx.Exec(tctx, stmts, sq.PP{})
if err != nil {
return err
}
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBAfter != resultHash {
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
}
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
if err != nil {
return err
}
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
return nil
}
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
dbdir := os.TempDir()
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
defer func() { _ = os.Remove(dbfile1) }()
err := os.MkdirAll(dbdir, os.ModePerm)
if err != nil {
return "", err
}
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
xdb, err := sqlx.Open("sqlite", url)
if err != nil {
return "", err
}
db := sq.NewDB(xdb, sq.DBOptions{})
_, err = db.Exec(ctx, schemaStr, sq.PP{})
if err != nil {
return "", err
}
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
}
func (db *Database) Ping(ctx context.Context) error { func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx) return db.db.Ping(ctx)
} }
func (db *Database) Version(ctx context.Context) (string, string, error) {
type rt struct {
Version string `db:"version"`
SourceID string `db:"sourceID"`
}
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
if err != nil {
return "", "", err
}
return resp.Version, resp.SourceID, nil
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) { func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault) return db.db.BeginTransaction(ctx, sql.LevelDefault)
} }

View File

@ -31,27 +31,13 @@ func (db *Database) CreateClient(ctx db.TxContext, userid models.UserID, ctype m
return entity, nil return entity, nil
} }
func (db *Database) ClearFCMTokens(ctx db.TxContext, fcmtoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
if err != nil {
return err
}
return nil
}
func (db *Database) ListClients(ctx db.TxContext, userid models.UserID) ([]models.Client, error) { func (db *Database) ListClients(ctx db.TxContext, userid models.UserID) ([]models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sq.QueryAll[models.Client](ctx, tx, "SELECT * FROM clients WHERE user_id = :uid ORDER BY clients.timestamp_created DESC, clients.client_id ASC", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe) return sq.QueryAll[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid ORDER BY clients.timestamp_created DESC, clients.client_id ASC", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
} }
func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) { func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) {
@ -60,7 +46,19 @@ func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid m
return models.Client{}, err return models.Client{}, err
} }
return sq.QuerySingle[models.Client](ctx, tx, "SELECT * FROM clients WHERE user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{ return sq.QuerySingle[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
"uid": userid,
"cid": clientid,
}, sq.SModeExtended, sq.Safe)
}
func (db *Database) GetClientOpt(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (*models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
return sq.QuerySingleOpt[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
"uid": userid, "uid": userid,
"cid": clientid, "cid": clientid,
}, sq.SModeExtended, sq.Safe) }, sq.SModeExtended, sq.Safe)
@ -72,7 +70,7 @@ func (db *Database) DeleteClient(ctx db.TxContext, clientid models.ClientID) err
return err return err
} }
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE client_id = :cid", sq.PP{"cid": clientid}) _, err = tx.Exec(ctx, "UPDATE clients SET deleted=1 WHERE deleted=0 AND client_id = :cid", sq.PP{"cid": clientid})
if err != nil { if err != nil {
return err return err
} }
@ -86,7 +84,7 @@ func (db *Database) DeleteClientsByFCM(ctx db.TxContext, fcmtoken string) error
return err return err
} }
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken}) _, err = tx.Exec(ctx, "UPDATE clients SET deleted=1 WHERE deleted=0 AND fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
if err != nil { if err != nil {
return err return err
} }
@ -100,7 +98,7 @@ func (db *Database) UpdateClientFCMToken(ctx db.TxContext, clientid models.Clien
return err return err
} }
_, err = tx.Exec(ctx, "UPDATE clients SET fcm_token = :vvv WHERE client_id = :cid", sq.PP{ _, err = tx.Exec(ctx, "UPDATE clients SET fcm_token = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
"vvv": fcmtoken, "vvv": fcmtoken,
"cid": clientid, "cid": clientid,
}) })
@ -117,7 +115,7 @@ func (db *Database) UpdateClientAgentModel(ctx db.TxContext, clientid models.Cli
return err return err
} }
_, err = tx.Exec(ctx, "UPDATE clients SET agent_model = :vvv WHERE client_id = :cid", sq.PP{ _, err = tx.Exec(ctx, "UPDATE clients SET agent_model = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
"vvv": agentModel, "vvv": agentModel,
"cid": clientid, "cid": clientid,
}) })
@ -134,7 +132,7 @@ func (db *Database) UpdateClientAgentVersion(ctx db.TxContext, clientid models.C
return err return err
} }
_, err = tx.Exec(ctx, "UPDATE clients SET agent_version = :vvv WHERE client_id = :cid", sq.PP{ _, err = tx.Exec(ctx, "UPDATE clients SET agent_version = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
"vvv": agentVersion, "vvv": agentVersion,
"cid": clientid, "cid": clientid,
}) })
@ -151,7 +149,7 @@ func (db *Database) UpdateClientDescriptionName(ctx db.TxContext, clientid model
return err return err
} }
_, err = tx.Exec(ctx, "UPDATE clients SET name = :vvv WHERE client_id = :cid", sq.PP{ _, err = tx.Exec(ctx, "UPDATE clients SET name = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
"vvv": descriptionName, "vvv": descriptionName,
"cid": clientid, "cid": clientid,
}) })

View File

@ -13,15 +13,21 @@ import (
"github.com/glebarez/go-sqlite" "github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq" "gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time" "time"
) )
type Database struct { type Database struct {
db sq.DB db sq.DB
pp *dbtools.DBPreprocessor pp *dbtools.DBPreprocessor
wal bool wal bool
name string
schemaVersion int
schema map[int]schema.Def
} }
func NewPrimaryDatabase(cfg server.Config) (*Database, error) { func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
qqdb.AddListener(pp) qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"} scndb := &Database{
db: qqdb,
pp: pp,
wal: conf.Journal == "WAL",
schemaVersion: schema.PrimarySchemaVersion,
schema: schema.PrimarySchema,
name: "primary",
}
return scndb, nil return scndb, nil
} }
@ -99,151 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err return err
} }
if currschema == 0 { if currschema == db.schemaVersion {
schemastr := schema.PrimarySchema[schema.PrimarySchemaVersion].SQL log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
schemahash := schema.PrimarySchema[schema.PrimarySchemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.PrimarySchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.PrimarySchemaVersion
} }
if currschema == 1 { for currschema < db.schemaVersion {
return errors.New("cannot autom. upgrade schema 1")
}
if currschema == 2 { if currschema == 0 {
return errors.New("cannot autom. upgrade schema 2") log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
}
if currschema == 3 { schemastr := db.schema[db.schemaVersion].SQL
schemahash := db.schema[db.schemaVersion].Hash
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash") _, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil { if err != nil {
return err return err
} }
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx) err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
if err != nil { if err != nil {
return err return err
} }
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash { err = db.WriteMetaString(tctx, "schema_hash", schemahash)
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)") if err != nil {
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)") return err
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)") }
return errors.New("database schema does not match (primary db)")
ppReInit = true
currschema = db.schemaVersion
} else { } else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)") log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4") err = db.migrateSingle(tctx, tx, currschema, currschema+1)
if err != nil {
return err
}
_, err = tx.Exec(tctx, schema.PrimaryMigration_3_4, sq.PP{}) currschema = currschema + 1
if err != nil {
return err
}
currschema = 4
err = db.WriteMetaInt(tctx, "schema", int64(currschema))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schema.PrimarySchema[currschema].Hash)
if err != nil {
return err
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4 succesfully")
ppReInit = true
}
if currschema == 4 {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 4 -> 5")
_, err = tx.Exec(tctx, schema.PrimaryMigration_4_5, sq.PP{})
if err != nil {
return err
}
currschema = 5
err = db.WriteMetaInt(tctx, "schema", int64(currschema))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schema.PrimarySchema[currschema].Hash)
if err != nil {
return err
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 4 -> 5 succesfully")
ppReInit = true
}
if currschema == 5 {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
} }
} }
if currschema != schema.PrimarySchemaVersion { if currschema != db.schemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema)) return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
} }
@ -263,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
return nil return nil
} }
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
if schemaFrom == schemaTo-1 {
migSQL := db.schema[schemaTo].MigScript
if migSQL == "" {
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
}
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
}
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
_, err = tx.Exec(tctx, stmts, sq.PP{})
if err != nil {
return err
}
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBAfter != resultHash {
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
}
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
if err != nil {
return err
}
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
return nil
}
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
dbdir := os.TempDir()
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
defer func() { _ = os.Remove(dbfile1) }()
err := os.MkdirAll(dbdir, os.ModePerm)
if err != nil {
return "", err
}
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
xdb, err := sqlx.Open("sqlite", url)
if err != nil {
return "", err
}
db := sq.NewDB(xdb, sq.DBOptions{})
_, err = db.Exec(ctx, schemaStr, sq.PP{})
if err != nil {
return "", err
}
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
}
func (db *Database) Ping(ctx context.Context) error { func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx) return db.db.Ping(ctx)
} }
func (db *Database) Version(ctx context.Context) (string, string, error) {
type rt struct {
Version string `db:"version"`
SourceID string `db:"sourceID"`
}
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
if err != nil {
return "", "", err
}
return resp.Version, resp.SourceID, nil
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) { func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault) return db.db.BeginTransaction(ctx, sql.LevelDefault)
} }

View File

@ -125,7 +125,7 @@ func (db *Database) SetDeliveryRetry(ctx db.TxContext, delivery models.Delivery)
} }
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{ _, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{
"next": scn.NextDeliveryTimestamp(time.Now()), "next": time2DB(scn.NextDeliveryTimestamp(time.Now())),
"rc": delivery.RetryCount + 1, "rc": delivery.RetryCount + 1,
"did": delivery.DeliveryID, "did": delivery.DeliveryID,
}) })

View File

@ -80,14 +80,10 @@ func (db *Database) DeleteMessage(ctx db.TxContext, messageID models.MessageID)
return nil return nil
} }
func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter, pageSize *int, inTok ct.CursorToken) ([]models.Message, ct.CursorToken, error) { func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter, pageSize *int, inTok ct.CursorToken) ([]models.Message, ct.CursorToken, int64, error) {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, ct.CursorToken{}, err return nil, ct.CursorToken{}, 0, err
}
if inTok.Mode == ct.CTMEnd {
return make([]models.Message, 0), ct.End(), nil
} }
pageCond := "1=1" pageCond := "1=1"
@ -105,21 +101,39 @@ func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter,
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC" orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
} }
sqlQuery := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause sqlQueryList := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
sqlQueryCount := "SELECT " + " COUNT(*) AS count FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
prepParams["tokts"] = inTok.Timestamp prepParams["tokts"] = inTok.Timestamp
prepParams["tokid"] = inTok.Id prepParams["tokid"] = inTok.Id
data, err := sq.QueryAll[models.Message](ctx, tx, sqlQuery, prepParams, sq.SModeExtended, sq.Safe) if inTok.Mode == ct.CTMEnd {
if err != nil {
return nil, ct.CursorToken{}, err dataCount, err := sq.QuerySingle[CountResponse](ctx, tx, sqlQueryCount, prepParams, sq.SModeFast, sq.Safe)
if err != nil {
return nil, ct.CursorToken{}, 0, err
}
return make([]models.Message, 0), ct.End(), dataCount.Count, nil
} }
if pageSize == nil || len(data) <= *pageSize { dataList, err := sq.QueryAll[models.Message](ctx, tx, sqlQueryList, prepParams, sq.SModeExtended, sq.Safe)
return data, ct.End(), nil if err != nil {
return nil, ct.CursorToken{}, 0, err
}
if pageSize == nil || len(dataList) <= *pageSize {
return dataList, ct.End(), int64(len(dataList)), nil
} else { } else {
outToken := ct.Normal(data[*pageSize-1].Timestamp(), data[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
return data[0:*pageSize], outToken, nil dataCount, err := sq.QuerySingle[CountResponse](ctx, tx, sqlQueryCount, prepParams, sq.SModeFast, sq.Safe)
if err != nil {
return nil, ct.CursorToken{}, 0, err
}
outToken := ct.Normal(dataList[*pageSize-1].Timestamp(), dataList[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
return dataList[0:*pageSize], outToken, dataCount.Count, nil
} }
} }

View File

@ -0,0 +1,26 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ListSenderNames(ctx db.TxContext, userid models.UserID, includeForeignSubscribed bool) ([]models.SenderNameStatistics, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
var sqlStr string
prepParams := sq.PP{"uid": userid}
if includeForeignSubscribed {
sqlStr = "SELECT sender_name AS name, MAX(timestamp_real) AS ts_last, MIN(timestamp_real) AS ts_first, COUNT(*) AS count FROM messages LEFT JOIN subscriptions AS subs on messages.channel_id = subs.channel_id WHERE (subs.subscriber_user_id = :uid AND subs.confirmed = 1) AND sender_NAME NOT NULL GROUP BY sender_name ORDER BY ts_last DESC"
} else {
sqlStr = "SELECT sender_name AS name, MAX(timestamp_real) AS ts_last, MIN(timestamp_real) AS ts_first, COUNT(*) AS count FROM messages WHERE sender_user_id = :uid AND sender_NAME NOT NULL GROUP BY sender_name ORDER BY ts_last DESC"
}
return sq.QueryAll[models.SenderNameStatistics](ctx, tx, sqlStr, prepParams, sq.SModeExtended, sq.Safe)
}

View File

@ -62,6 +62,15 @@ func (db *Database) GetUser(ctx db.TxContext, userid models.UserID) (models.User
return sq.QuerySingle[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe) return sq.QuerySingle[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
} }
func (db *Database) GetUserOpt(ctx db.TxContext, userid models.UserID) (*models.User, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
return sq.QuerySingleOpt[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
}
func (db *Database) UpdateUserUsername(ctx db.TxContext, userid models.UserID, username *string) error { func (db *Database) UpdateUserUsername(ctx db.TxContext, userid models.UserID, username *string) error {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {

View File

@ -23,3 +23,7 @@ func time2DBOpt(t *time.Time) *int64 {
} }
return langext.Ptr(t.UnixMilli()) return langext.Ptr(t.UnixMilli())
} }
type CountResponse struct {
Count int64 `db:"count"`
}

View File

@ -13,15 +13,21 @@ import (
"github.com/glebarez/go-sqlite" "github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq" "gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time" "time"
) )
type Database struct { type Database struct {
db sq.DB db sq.DB
pp *dbtools.DBPreprocessor pp *dbtools.DBPreprocessor
wal bool wal bool
name string
schemaVersion int
schema map[int]schema.Def
} }
func NewRequestsDatabase(cfg server.Config) (*Database, error) { func NewRequestsDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewRequestsDatabase(cfg server.Config) (*Database, error) {
qqdb.AddListener(pp) qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"} scndb := &Database{
db: qqdb,
pp: pp,
wal: conf.Journal == "WAL",
schemaVersion: schema.RequestsSchemaVersion,
schema: schema.RequestsSchema,
name: "requests",
}
return scndb, nil return scndb, nil
} }
@ -99,57 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err return err
} }
if currschema == 0 { if currschema == db.schemaVersion {
schemastr := schema.RequestsSchema[schema.RequestsSchemaVersion].SQL log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
schemahash := schema.RequestsSchema[schema.RequestsSchemaVersion].Hash
schemahash, err := sq.HashGoSqliteSchema(tctx, schemastr)
if err != nil {
return err
}
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.RequestsSchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.LogsSchemaVersion
} }
if currschema == 1 { for currschema < db.schemaVersion {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash") if currschema == 0 {
if err != nil { log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.RequestsSchema[currschema].Hash { schemastr := db.schema[db.schemaVersion].SQL
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (requests db)") schemahash := db.schema[db.schemaVersion].Hash
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (requests db)")
log.Debug().Str("schemaHashAsset", schema.RequestsSchema[currschema].Hash).Msg("Schema (requests db)") _, err = tx.Exec(tctx, schemastr, sq.PP{})
return errors.New("database schema does not match (requests db)") if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = db.schemaVersion
} else { } else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (requests db)") log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
err = db.migrateSingle(tctx, tx, currschema, currschema+1)
if err != nil {
return err
}
currschema = currschema + 1
} }
} }
if currschema != schema.RequestsSchemaVersion { if currschema != db.schemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema)) return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
} }
@ -169,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
return nil return nil
} }
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
if schemaFrom == schemaTo-1 {
migSQL := db.schema[schemaTo].MigScript
if migSQL == "" {
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
}
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
}
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
}
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
_, err = tx.Exec(tctx, stmts, sq.PP{})
if err != nil {
return err
}
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDBAfter != resultHash {
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
}
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
if err != nil {
return err
}
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
return nil
}
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
dbdir := os.TempDir()
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
defer func() { _ = os.Remove(dbfile1) }()
err := os.MkdirAll(dbdir, os.ModePerm)
if err != nil {
return "", err
}
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
xdb, err := sqlx.Open("sqlite", url)
if err != nil {
return "", err
}
db := sq.NewDB(xdb, sq.DBOptions{})
_, err = db.Exec(ctx, schemaStr, sq.PP{})
if err != nil {
return "", err
}
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
}
func (db *Database) Ping(ctx context.Context) error { func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx) return db.db.Ping(ctx)
} }
func (db *Database) Version(ctx context.Context) (string, string, error) {
type rt struct {
Version string `db:"version"`
SourceID string `db:"sourceID"`
}
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
if err != nil {
return "", "", err
}
return resp.Version, resp.SourceID, nil
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) { func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault) return db.db.BeginTransaction(ctx, sql.LevelDefault)
} }

View File

@ -3,8 +3,9 @@ package schema
import _ "embed" import _ "embed"
type Def struct { type Def struct {
SQL string SQL string
Hash string Hash string
MigScript string
} }
//go:embed primary_1.ddl //go:embed primary_1.ddl
@ -22,11 +23,29 @@ var primarySchema4 string
//go:embed primary_5.ddl //go:embed primary_5.ddl
var primarySchema5 string var primarySchema5 string
//go:embed primary_6.ddl
var primarySchema6 string
//go:embed primary_7.ddl
var primarySchema7 string
//go:embed primary_8.ddl
var primarySchema8 string
//go:embed primary_migration_3_4.ddl //go:embed primary_migration_3_4.ddl
var PrimaryMigration_3_4 string var primaryMigration_3_4 string
//go:embed primary_migration_4_5.ddl //go:embed primary_migration_4_5.ddl
var PrimaryMigration_4_5 string var primaryMigration_4_5 string
//go:embed primary_migration_5_6.ddl
var primaryMigration_5_6 string
//go:embed primary_migration_6_7.ddl
var primaryMigration_6_7 string
//go:embed primary_migration_7_8.ddl
var primaryMigration_7_8 string
//go:embed requests_1.ddl //go:embed requests_1.ddl
var requestsSchema1 string var requestsSchema1 string
@ -35,26 +54,29 @@ var requestsSchema1 string
var logsSchema1 string var logsSchema1 string
var PrimarySchema = map[int]Def{ var PrimarySchema = map[int]Def{
0: {"", ""}, 0: {"", "", ""},
1: {primarySchema1, "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2"}, 1: {primarySchema1, "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2", ""},
2: {primarySchema2, "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a"}, 2: {primarySchema2, "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a", ""},
3: {primarySchema3, "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8"}, 3: {primarySchema3, "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8", ""},
4: {primarySchema4, "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca"}, 4: {primarySchema4, "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca", primaryMigration_3_4},
5: {primarySchema5, "9d6217ba4a3503cfe090f72569367f95a413bb14e9effe49ffeabbf255bce8dd"}, 5: {primarySchema5, "9d6217ba4a3503cfe090f72569367f95a413bb14e9effe49ffeabbf255bce8dd", primaryMigration_4_5},
6: {primarySchema6, "8e83d20bcd008082713f248ae8cd558335a37a37ce90bd8c86e782da640ee160", primaryMigration_5_6},
7: {primarySchema7, "90d8dbc460afe025f9b74cda5c16bb8e58b178df275223bd2531907a8d8c36c3", primaryMigration_6_7},
8: {primarySchema8, "746f6005c7a573b8816e5993ecd1d949fe2552b0134ba63bab8b4d5b2b5058ad", primaryMigration_7_8},
} }
var PrimarySchemaVersion = 5 var PrimarySchemaVersion = len(PrimarySchema) - 1
var RequestsSchema = map[int]Def{ var RequestsSchema = map[int]Def{
0: {"", ""}, 0: {"", "", ""},
1: {requestsSchema1, "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9"}, 1: {requestsSchema1, "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9", ""},
} }
var RequestsSchemaVersion = 1 var RequestsSchemaVersion = len(RequestsSchema) - 1
var LogsSchema = map[int]Def{ var LogsSchema = map[int]Def{
0: {"", ""}, 0: {"", "", ""},
1: {logsSchema1, "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7"}, 1: {logsSchema1, "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7", ""},
} }
var LogsSchemaVersion = 1 var LogsSchemaVersion = len(LogsSchema) - 1

View File

@ -0,0 +1,234 @@
CREATE TABLE users
(
user_id TEXT NOT NULL,
username TEXT NULL DEFAULT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
quota_used INTEGER NOT NULL DEFAULT '0',
quota_used_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL,
PRIMARY KEY (user_id)
) STRICT;
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
CREATE TABLE keytokens
(
keytoken_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastused INTEGER NULL DEFAULT NULL,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
channels TEXT NOT NULL,
token TEXT NOT NULL,
permissions TEXT NOT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (keytoken_id)
) STRICT;
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
CREATE TABLE clients
(
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
fcm_token TEXT NOT NULL,
name TEXT NULL,
timestamp_created INTEGER NOT NULL,
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL,
PRIMARY KEY (client_id)
) STRICT;
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
CREATE TABLE channels
(
channel_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
internal_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description_name TEXT NULL,
subscribe_key TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (channel_id)
) STRICT;
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
CREATE TABLE subscriptions
(
subscription_id TEXT NOT NULL,
subscriber_user_id TEXT NOT NULL,
channel_owner_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
PRIMARY KEY (subscription_id)
) STRICT;
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
CREATE TABLE messages
(
message_id TEXT NOT NULL,
sender_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
sender_ip TEXT NOT NULL,
sender_name TEXT NULL,
timestamp_real INTEGER NOT NULL,
timestamp_client INTEGER NULL,
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL,
used_key_id TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (message_id)
) STRICT;
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id COLLATE BINARY);
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
CREATE INDEX "idx_messages_usedkey" ON messages (sender_user_id, used_key_id);
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
CREATE VIRTUAL TABLE messages_fts USING fts5
(
channel_internal_name,
sender_name,
title,
content,
tokenize = unicode61,
content = 'messages',
content_rowid = 'rowid'
);
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
END;
CREATE TABLE deliveries
(
delivery_id TEXT NOT NULL,
message_id TEXT NOT NULL,
receiver_user_id TEXT NOT NULL,
receiver_client_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery INTEGER NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
) STRICT;
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
CREATE TABLE compat_ids
(
old INTEGER NOT NULL,
new TEXT NOT NULL,
type TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
CREATE TABLE compat_acks
(
user_id TEXT NOT NULL,
message_id TEXT NOT NULL
) STRICT;
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
CREATE TABLE compat_clients
(
client_id TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)

View File

@ -0,0 +1,238 @@
CREATE TABLE users
(
user_id TEXT NOT NULL,
username TEXT NULL DEFAULT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
quota_used INTEGER NOT NULL DEFAULT '0',
quota_used_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL,
PRIMARY KEY (user_id)
) STRICT;
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
CREATE TABLE keytokens
(
keytoken_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastused INTEGER NULL DEFAULT NULL,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
channels TEXT NOT NULL,
token TEXT NOT NULL,
permissions TEXT NOT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (keytoken_id)
) STRICT;
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
CREATE TABLE clients
(
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
fcm_token TEXT NOT NULL,
name TEXT NULL,
timestamp_created INTEGER NOT NULL,
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (client_id)
) STRICT;
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE INDEX "idx_clients_deleted" ON clients (deleted);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
CREATE TABLE channels
(
channel_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
internal_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description_name TEXT NULL,
subscribe_key TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (channel_id)
) STRICT;
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
CREATE TABLE subscriptions
(
subscription_id TEXT NOT NULL,
subscriber_user_id TEXT NOT NULL,
channel_owner_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
PRIMARY KEY (subscription_id)
) STRICT;
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
CREATE TABLE messages
(
message_id TEXT NOT NULL,
sender_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
sender_ip TEXT NOT NULL,
sender_name TEXT NULL,
timestamp_real INTEGER NOT NULL,
timestamp_client INTEGER NULL,
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL,
used_key_id TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (message_id)
) STRICT;
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id COLLATE BINARY);
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
CREATE INDEX "idx_messages_usedkey" ON messages (sender_user_id, used_key_id);
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
CREATE VIRTUAL TABLE messages_fts USING fts5
(
channel_internal_name,
sender_name,
title,
content,
tokenize = unicode61,
content = 'messages',
content_rowid = 'rowid'
);
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
END;
CREATE TABLE deliveries
(
delivery_id TEXT NOT NULL,
message_id TEXT NOT NULL,
receiver_user_id TEXT NOT NULL,
receiver_client_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery INTEGER NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
) STRICT;
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
CREATE TABLE compat_ids
(
old INTEGER NOT NULL,
new TEXT NOT NULL,
type TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
CREATE TABLE compat_acks
(
user_id TEXT NOT NULL,
message_id TEXT NOT NULL
) STRICT;
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
CREATE TABLE compat_clients
(
client_id TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)

View File

@ -0,0 +1,238 @@
CREATE TABLE users
(
user_id TEXT NOT NULL,
username TEXT NULL DEFAULT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
quota_used INTEGER NOT NULL DEFAULT '0',
quota_used_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL,
PRIMARY KEY (user_id)
) STRICT;
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
CREATE TABLE keytokens
(
keytoken_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastused INTEGER NULL DEFAULT NULL,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
channels TEXT NOT NULL,
token TEXT NOT NULL,
permissions TEXT NOT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (keytoken_id)
) STRICT;
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
CREATE TABLE clients
(
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
fcm_token TEXT NOT NULL,
name TEXT NULL,
timestamp_created INTEGER NOT NULL,
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (client_id)
) STRICT;
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE INDEX "idx_clients_deleted" ON clients (deleted);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token) WHERE deleted=0;
CREATE TABLE channels
(
channel_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
internal_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description_name TEXT NULL,
subscribe_key TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (channel_id)
) STRICT;
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
CREATE TABLE subscriptions
(
subscription_id TEXT NOT NULL,
subscriber_user_id TEXT NOT NULL,
channel_owner_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
PRIMARY KEY (subscription_id)
) STRICT;
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
CREATE TABLE messages
(
message_id TEXT NOT NULL,
sender_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
sender_ip TEXT NOT NULL,
sender_name TEXT NULL,
timestamp_real INTEGER NOT NULL,
timestamp_client INTEGER NULL,
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL,
used_key_id TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (message_id)
) STRICT;
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id COLLATE BINARY);
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
CREATE INDEX "idx_messages_usedkey" ON messages (sender_user_id, used_key_id);
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
CREATE VIRTUAL TABLE messages_fts USING fts5
(
channel_internal_name,
sender_name,
title,
content,
tokenize = unicode61,
content = 'messages',
content_rowid = 'rowid'
);
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
END;
CREATE TABLE deliveries
(
delivery_id TEXT NOT NULL,
message_id TEXT NOT NULL,
receiver_user_id TEXT NOT NULL,
receiver_client_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery INTEGER NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
) STRICT;
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
CREATE TABLE compat_ids
(
old INTEGER NOT NULL,
new TEXT NOT NULL,
type TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
CREATE TABLE compat_acks
(
user_id TEXT NOT NULL,
message_id TEXT NOT NULL
) STRICT;
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
CREATE TABLE compat_clients
(
client_id TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)

View File

@ -0,0 +1,49 @@
DROP INDEX "idx_deliveries_receiver";
CREATE TABLE deliveries_new
(
delivery_id TEXT NOT NULL,
message_id TEXT NOT NULL,
receiver_user_id TEXT NOT NULL,
receiver_client_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery INTEGER NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
) STRICT;
UPDATE deliveries SET next_delivery = NULL;
INSERT INTO deliveries_new
SELECT
delivery_id,
message_id,
receiver_user_id,
receiver_client_id,
timestamp_created,
timestamp_finalized,
status,
retry_count,
next_delivery,
fcm_message_id
FROM deliveries;
DROP TABLE deliveries;
ALTER TABLE deliveries_new RENAME TO deliveries;
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);

View File

@ -0,0 +1,52 @@
DROP INDEX "idx_clients_userid";
DROP INDEX "idx_clients_fcmtoken";
CREATE TABLE clients_new
(
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
fcm_token TEXT NOT NULL,
name TEXT NULL,
timestamp_created INTEGER NOT NULL,
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (client_id)
) STRICT;
INSERT INTO clients_new
SELECT
client_id,
user_id,
type,
fcm_token,
name,
timestamp_created,
agent_model,
agent_version,
0 AS deleted
FROM clients;
DROP TABLE clients;
ALTER TABLE clients_new RENAME TO clients;
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE INDEX "idx_clients_deleted" ON clients (deleted);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);

View File

@ -0,0 +1,7 @@
DROP INDEX "idx_clients_fcmtoken";
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token) WHERE deleted=0;

View File

@ -95,3 +95,20 @@ func (sc *SimpleContext) RollbackTransaction() {
sc.transaction = nil sc.transaction = nil
return return
} }
func Run[TResp any](outctx context.Context, f func(ctx db.TxContext) (TResp, error)) (TResp, error) {
sctx := CreateSimpleContext(outctx, nil)
defer sctx.Cancel()
res, err := f(sctx)
if err != nil {
return *new(TResp), err
}
err = sctx.CommitTransaction()
if err != nil {
return *new(TResp), err
}
return res, nil
}

View File

@ -10,8 +10,8 @@ require (
github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/validator/v10 v10.22.1
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/viney-shih/go-lock v1.1.2
gogs.mikescher.com/BlackForestBytes/goext v0.0.513 gogs.mikescher.com/BlackForestBytes/goext v0.0.513
gopkg.in/loremipsum.v1 v1.1.2 gopkg.in/loremipsum.v1 v1.1.2
) )
@ -44,7 +44,6 @@ require (
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/viney-shih/go-lock v1.1.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect

View File

@ -114,10 +114,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8=
go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw=
gogs.mikescher.com/BlackForestBytes/goext v0.0.511 h1:vAEhXdexKlLTNf/mGHzemp/4rzmv7n2jf5l4NK38tIw=
gogs.mikescher.com/BlackForestBytes/goext v0.0.511/go.mod h1:9Q9EjraeE3yih7EXgBlnwLLJXWuRZNsl7s5TVTh3aOU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.512 h1:cdLUi1bSnGujtx8/K0fPql142aOvUyNPt+8aWMKKDFk=
gogs.mikescher.com/BlackForestBytes/goext v0.0.512/go.mod h1:9Q9EjraeE3yih7EXgBlnwLLJXWuRZNsl7s5TVTh3aOU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.513 h1:zGb5n220AYNElzQs611RYXfZlnUw6/VJJesfLftphkQ= gogs.mikescher.com/BlackForestBytes/goext v0.0.513 h1:zGb5n220AYNElzQs611RYXfZlnUw6/VJJesfLftphkQ=
gogs.mikescher.com/BlackForestBytes/goext v0.0.513/go.mod h1:9Q9EjraeE3yih7EXgBlnwLLJXWuRZNsl7s5TVTh3aOU= gogs.mikescher.com/BlackForestBytes/goext v0.0.513/go.mod h1:9Q9EjraeE3yih7EXgBlnwLLJXWuRZNsl7s5TVTh3aOU=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8= golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=

View File

@ -134,12 +134,30 @@ func (j *DeliveryRetryJob) execute() (fastrr bool, err error) {
func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery models.Delivery) { func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery models.Delivery) {
client, err := j.app.Database.Primary.GetClient(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID) client, err := j.app.Database.Primary.GetClientOpt(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID)
if err != nil { if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client") log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client")
ctx.RollbackTransaction() ctx.RollbackTransaction()
return return
} }
if client == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client (client no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [client==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
msg, err := j.app.Database.Primary.GetMessage(ctx, delivery.MessageID, true) msg, err := j.app.Database.Primary.GetMessage(ctx, delivery.MessageID, true)
if err != nil { if err != nil {
@ -148,25 +166,6 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
return return
} }
user, err := j.app.Database.Primary.GetUser(ctx, delivery.ReceiverUserID)
if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Msg("Failed to get user")
ctx.RollbackTransaction()
return
}
channel, err := j.app.Database.Primary.GetChannelByID(ctx, msg.ChannelID)
if err != nil {
log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if channel == nil {
log.Error().Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if msg.Deleted { if msg.Deleted {
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery) err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil { if err != nil {
@ -174,9 +173,68 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
ctx.RollbackTransaction() ctx.RollbackTransaction()
return return
} }
} else { log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [message.deleted] (set to FAILURE)")
fcmDelivID, err := j.app.DeliverMessage(ctx, user, client, *channel, msg) err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
user, err := j.app.Database.Primary.GetUserOpt(ctx, delivery.ReceiverUserID)
if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Msg("Failed to get user")
ctx.RollbackTransaction()
return
}
if user == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get user (user no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [user==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
channel, err := j.app.Database.Primary.GetChannelByID(ctx, msg.ChannelID)
if err != nil {
log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if channel == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel (client no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [channel==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
{
fcmDelivID, err := j.app.DeliverMessage(ctx, *user, *client, *channel, msg)
if err == nil { if err == nil {
err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID) err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID)
if err != nil { if err != nil {
@ -201,8 +259,11 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
} }
} }
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
} }
err = ctx.CommitTransaction()
} }

View File

@ -9,25 +9,19 @@ import (
"blackforestbytes.com/simplecloudnotifier/push" "blackforestbytes.com/simplecloudnotifier/push"
"context" "context"
"errors" "errors"
"fmt"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
golock "github.com/viney-shih/go-lock" golock "github.com/viney-shih/go-lock"
"gogs.mikescher.com/BlackForestBytes/goext/ginext" "gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext" "gogs.mikescher.com/BlackForestBytes/goext/syncext"
"net" "net"
"os" "os"
"os/signal" "os/signal"
"regexp"
"strings" "strings"
"syscall" "syscall"
"time" "time"
) )
var rexWhitespaceStart = rext.W(regexp.MustCompile("^\\s+"))
var rexWhitespaceEnd = rext.W(regexp.MustCompile("\\s+$"))
var rexNormalizeUsername = rext.W(regexp.MustCompile("[^[:alnum:]\\-_ ]"))
var rexCompatTitleChannel = rext.W(regexp.MustCompile("^\\[(?P<channel>[A-Za-z\\-0-9_ ]+)] (?P<title>(.|\\r|\\n)+)$"))
type Application struct { type Application struct {
Config scn.Config Config scn.Config
Gin *ginext.GinWrapper Gin *ginext.GinWrapper
@ -279,9 +273,24 @@ func (app *Application) NormalizeUsername(v string) string {
} }
func (app *Application) DeliverMessage(ctx context.Context, user models.User, client models.Client, channel models.Channel, msg models.Message) (string, error) { func (app *Application) DeliverMessage(ctx context.Context, user models.User, client models.Client, channel models.Channel, msg models.Message) (string, error) {
fcmDelivID, err := app.Pusher.SendNotification(ctx, user, client, channel, msg) fcmDelivID, errCode, err := app.Pusher.SendNotification(ctx, user, client, channel, msg)
if err != nil { if err != nil {
log.Warn().Str("MessageID", msg.MessageID.String()).Str("ClientID", client.ClientID.String()).Err(err).Msg("FCM Delivery failed") log.Warn().Str("MessageID", msg.MessageID.String()).Str("ClientID", client.ClientID.String()).Err(err).Msg("FCM Delivery failed")
if errCode == "UNREGISTERED" {
log.Warn().Msg(fmt.Sprintf("Auto-Delete client %s of user %s (FCM is UNREGISTERED)", client.ClientID, user.UserID))
_, _ = simplectx.Run(ctx, func(ctx db.TxContext) (any, error) {
err = app.Database.Primary.DeleteClient(ctx, client.ClientID)
if err != nil {
log.Err(err).Str("ClientID", client.ClientID.String()).Msg("Failed to delete client")
}
return nil, nil
})
}
return "", err return "", err
} }
return fcmDelivID, nil return fcmDelivID, nil

View File

@ -176,7 +176,7 @@ func (app *Application) SendMessage(g *gin.Context, ctx *AppContext, UserID *mod
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc token msg-counter", err)) return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc token msg-counter", err))
} }
log.Info().Msg(fmt.Sprintf("Sending new notification %s for user %s", msg.MessageID, UserID)) log.Info().Msg(fmt.Sprintf("Sending new notification %s for user %s (to %d active subscriptions)", msg.MessageID, UserID, len(activeSubscriptions)))
for _, sub := range activeSubscriptions { for _, sub := range activeSubscriptions {
clients, err := app.Database.Primary.ListClients(ctx, sub.SubscriberUserID) clients, err := app.Database.Primary.ListClients(ctx, sub.SubscriberUserID)
@ -186,6 +186,8 @@ func (app *Application) SendMessage(g *gin.Context, ctx *AppContext, UserID *mod
for _, client := range clients { for _, client := range clients {
log.Info().Msg(fmt.Sprintf("Create delivery for message %s to client %s (of user %s)", msg.MessageID, client.ClientID, client.UserID))
fcmDelivID, err := app.DeliverMessage(ctx, user, client, channel, msg) fcmDelivID, err := app.DeliverMessage(ctx, user, client, channel, msg)
if err != nil { if err != nil {
_, err = app.Database.Primary.CreateRetryDelivery(ctx, client, msg) _, err = app.Database.Primary.CreateRetryDelivery(ctx, client, msg)

View File

@ -9,7 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3" "github.com/glebarez/go-sqlite"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/dataext" "gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/exerr" "gogs.mikescher.com/BlackForestBytes/goext/exerr"
@ -232,9 +232,18 @@ func isSqlite3Busy(r ginext.HTTPResponse) bool {
if errwrap, ok := r.(interface{ Unwrap() error }); ok && errwrap != nil { if errwrap, ok := r.(interface{ Unwrap() error }); ok && errwrap != nil {
orig := exerr.OriginalError(errwrap.Unwrap()) orig := exerr.OriginalError(errwrap.Unwrap())
var sqlite3Err sqlite3.Error var sqliteErr *sqlite.Error
if errors.As(orig, &sqlite3Err) { if errors.As(orig, &sqliteErr) {
if sqlite3Err.Code == 5 { // [5] == SQLITE_BUSY if sqliteErr.Code() == 5 { // [5] == SQLITE_BUSY
return true
}
if sqliteErr.Code() == 517 { // [517] == SQLITE_BUSY_SNAPSHOT
return true
}
if sqliteErr.Code() == 261 { // [261] == SQLITE_BUSY_RECOVERY
return true
}
if sqliteErr.Code() == 773 { // [773] == SQLITE_BUSY_TIMEOUT
return true return true
} }
} }

View File

@ -3,9 +3,9 @@ package models
type Channel struct { type Channel struct {
ChannelID ChannelID `db:"channel_id" json:"channel_id"` ChannelID ChannelID `db:"channel_id" json:"channel_id"`
OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"` OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"`
InternalName string `db:"internal_name" json:"internal_name"` InternalName string `db:"internal_name" json:"internal_name"` // = InternalName, used for sending, normalized, cannot be changed
DisplayName string `db:"display_name" json:"display_name"` DisplayName string `db:"display_name" json:"display_name"` // = DisplayName, used for display purposes, can be changed, initially equals InternalName
DescriptionName *string `db:"description_name" json:"description_name"` DescriptionName *string `db:"description_name" json:"description_name"` // = DescriptionName, (optional), longer description text, initally nil
SubscribeKey string `db:"subscribe_key" json:"subscribe_key" jsonfilter:"INCLUDE_KEY"` // can be nil, depending on endpoint SubscribeKey string `db:"subscribe_key" json:"subscribe_key" jsonfilter:"INCLUDE_KEY"` // can be nil, depending on endpoint
TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"` TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"`
TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"` TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"`

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