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:
build_job:
build_server:
name: Build Docker Container
runs-on: bfb-cicd-latest
steps:
@ -24,9 +24,49 @@ jobs:
- run: cd "${{ gitea.workspace }}/scnserver" && make 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
needs: [build_job]
needs: [build_server, test_server]
runs-on: ubuntu-latest
steps:
- name: Execute deploy on remote (via ssh)

View File

@ -4,6 +4,12 @@ run:
flutter pub run build_runner build
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:
dart analyze

View File

@ -37,6 +37,7 @@ android {
ndkVersion flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
@ -55,6 +56,7 @@ android {
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
signingConfigs {
@ -77,4 +79,9 @@ flutter {
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"/>
</intent-filter>
</activity>
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<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 {
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View File

@ -1,13 +1,27 @@
import UIKit
import Flutter
import flutter_local_notifications
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> 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)
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/subscription.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/globals.dart';
import 'package:simplecloudnotifier/state/request_log.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/utils/toaster.dart';
@ -26,6 +27,27 @@ enum ChannelSelector {
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 {
static const String _base = 'https://simplecloudnotifier.de/api/v2';
@ -33,7 +55,7 @@ class APIClient {
required String name,
required String method,
required String relURL,
Map<String, String>? query,
Map<String, Iterable<String>>? query,
required T Function(Map<String, dynamic> json)? fn,
dynamic jsonBody,
String? authToken,
@ -45,7 +67,7 @@ class APIClient {
final req = http.Request(method, uri);
print('[REQUEST|RUN] [${method}] ${name}');
print('[REQUEST|RUN] [${method}] ${name} | ${uri.toString()}');
if (jsonBody != null) {
req.body = jsonEncode(jsonBody);
@ -185,7 +207,9 @@ class APIClient {
name: 'getChannelList',
method: 'GET',
relURL: 'users/${auth.getUserID()}/channels',
query: {'selector': sel.apiKey},
query: {
'selector': [sel.apiKey]
},
fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>),
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(
name: 'getMessageList',
method: 'GET',
relURL: 'messages',
query: {
'next_page_token': pageToken,
if (pageSize != null) 'page_size': pageSize.toString(),
if (channelIDs != null) 'channel_id': channelIDs.join(","),
'next_page_token': [pageToken],
if (pageSize != null) 'page_size': [pageSize.toString()],
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(),
);
}
static Future<Message> getMessage(TokenSource auth, String msgid) async {
static Future<SCNMessage> getMessage(TokenSource auth, String msgid) async {
return await _request(
name: 'getMessage',
method: 'GET',
relURL: 'messages/$msgid',
query: {},
fn: Message.fromJson,
fn: SCNMessage.fromJson,
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 {
return await _request(
name: 'getClientList',
@ -314,4 +368,8 @@ class APIClient {
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 {
final VoidCallback? onPressed;
final IconData icon;
final Object heroTag;
const HidableFAB({
super.key,
this.onPressed,
required this.icon,
required this.heroTag,
});
Widget build(BuildContext context) {
return Visibility(
visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown
child: FloatingActionButton(
heroTag: this.heroTag,
onPressed: onPressed,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))),
elevation: 2.0,

View File

@ -1,18 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/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/utils/navi.dart';
class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
const SCNAppBar({
class SCNAppBar extends StatefulWidget implements PreferredSizeWidget {
SCNAppBar({
Key? key,
required this.title,
required this.showThemeSwitch,
required this.showDebug,
required this.showSearch,
required this.showShare,
this.onShare = null,
@ -20,16 +23,33 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final bool showThemeSwitch;
final bool showDebug;
final bool showSearch;
final bool showShare;
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
Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
var actions = <Widget>[];
if (showDebug) {
if (cfg.showDebugButton) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug',
@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
));
}
if (showThemeSwitch) {
if (widget.showThemeSwitch) {
actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton(
icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon),
@ -48,54 +68,118 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
),
));
} else {
actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
actions.add(_buildSpacer());
}
if (showSearch) {
if (widget.showSearch) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidFilter),
tooltip: 'Filter',
onPressed: () => _showFilterDialog(context),
));
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
tooltip: 'Search',
onPressed: () {/*TODO*/},
onPressed: () => AppBarState().setShowSearchField(true),
));
} else if (showShare) {
} else if (widget.showShare) {
actions.add(_buildSpacer());
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidShareNodes),
tooltip: 'Share',
onPressed: onShare ?? () {},
onPressed: widget.onShare ?? () {},
));
} else {
actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
actions.add(_buildSpacer());
actions.add(_buildSpacer());
}
return AppBar(
title: Text(title ?? 'Simple Cloud Notifier 2.0'),
actions: actions,
backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(),
return Consumer<AppBarState>(builder: (context, value, child) {
if (value.showSearchField) {
return AppBar(
leading: IconButton(
icon: const Icon(FontAwesomeIcons.solidArrowLeft),
onPressed: () {
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
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
Widget _buildSearchTextField(BuildContext context) {
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:provider/provider.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget {
AppBarProgressIndicator({required this.show});
final bool show;
@override
Size get preferredSize => Size(double.infinity, 1.0);
@override
Widget build(BuildContext context) {
return Consumer<AppBarState>(
builder: (context, value, child) {
if (value.loadingIndeterminate) {
return LinearProgressIndicator(value: null);
} else {
return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
}
},
);
if (show) {
return LinearProgressIndicator(value: null);
} 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,
this.title,
this.showThemeSwitch = true,
this.showDebug = true,
this.showSearch = true,
this.showShare = false,
this.onShare = null,
@ -16,7 +15,6 @@ class SCNScaffold extends StatelessWidget {
final Widget child;
final String? title;
final bool showThemeSwitch;
final bool showDebug;
final bool showSearch;
final bool showShare;
final void Function()? onShare;
@ -27,7 +25,6 @@ class SCNScaffold extends StatelessWidget {
appBar: SCNAppBar(
title: title,
showThemeSwitch: showThemeSwitch,
showDebug: showDebug,
showSearch: showSearch,
showShare: showShare,
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:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.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/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_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/state/application_log.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/app_auth.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/notifier.dart';
import 'package:toastification/toastification.dart';
import 'firebase_options.dart';
@ -39,20 +47,10 @@ void main() async {
Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(MessageAdapter());
Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter());
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>...');
try {
@ -63,13 +61,23 @@ void main() async {
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>...');
try {
await Hive.openBox<Message>('scn-message-cache');
await Hive.openBox<SCNMessage>('scn-message-cache');
} catch (exc, trace) {
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);
}
@ -98,20 +106,19 @@ void main() async {
final appAuth = AppAuth(); // ensure UserAccount is loaded
if (appAuth.isAuth()) {
try {
print('[INIT] Load User...');
await appAuth.loadUser();
//TODO fallback to cached user (perhaps event use cached client (if exists) directly and only update/load in background)
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
}
try {
print('[INIT] Load Client...');
await appAuth.loadClient();
//TODO fallback to cached client (perhaps event use cached client (if exists) directly and only update/load in background)
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
}
// load user+client in background
() async {
try {
await appAuth.loadUser();
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
}
try {
await appAuth.loadClient();
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
}
}();
}
if (!Platform.isLinux) {
@ -147,6 +154,46 @@ void main() async {
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');
runApp(
@ -155,6 +202,7 @@ void main() async {
ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false),
ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false),
ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false),
ChangeNotifierProvider(create: (context) => AppSettings(), lazy: false),
],
child: SCNApp(),
),
@ -164,6 +212,8 @@ void main() async {
class SCNApp extends StatelessWidget {
SCNApp({super.key});
static var materialKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return ToastificationWrapper(
@ -174,6 +224,7 @@ class SCNApp extends StatelessWidget {
),
child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp(
navigatorKey: SCNApp.materialKey,
title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
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 {
final acc = AppAuth();
@ -224,18 +281,129 @@ void setFirebaseToken(String fcmToken) async {
}
}
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
// a firebase message was received while the app was in the background or terminated
await _receiveMessage(message, false);
}
@pragma('vm:entry-point')
void _onForegroundMessage(RemoteMessage message) {
// a firebase message was received while the app was in the foreground
_receiveMessage(message, true);
}
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
// ensure init
Hive.openBox<SCNLog>('scn-logs');
try {
// 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'}');
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) {
return APIError(
success: json['success'] as bool,
error: (json['error'] as double).toInt(),
error: (json['error'] as num).toInt(),
errhighlight: json['errhighlight'] as String,
message: json['message'] as String,
);

View File

@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable {
@HiveField(10)
final String ownerUserID;
@HiveField(11)
final String internalName;
final String internalName; // = InternalName, used for sending, normalized, cannot be changed
@HiveField(12)
final String displayName;
final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName
@HiveField(13)
final String? descriptionName;
final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil
@HiveField(14)
final String? subscribeKey;
@HiveField(15)
@ -70,11 +70,21 @@ class Channel extends HiveObject implements FieldDebuggable {
('messagesSent', '${this.messagesSent}'),
];
}
ChannelPreview toPreview() {
return ChannelPreview(
channelID: this.channelID,
ownerUserID: this.ownerUserID,
internalName: this.internalName,
displayName: this.displayName,
descriptionName: this.descriptionName,
);
}
}
class ChannelWithSubscription {
final Channel channel;
final Subscription subscription;
final Subscription? subscription;
ChannelWithSubscription({
required this.channel,
@ -84,7 +94,7 @@ class ChannelWithSubscription {
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
return ChannelWithSubscription(
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) {
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:simplecloudnotifier/state/interfaces.dart';
part 'message.g.dart';
part 'scn_message.g.dart';
@HiveType(typeId: 105)
class Message extends HiveObject implements FieldDebuggable {
class SCNMessage extends HiveObject implements FieldDebuggable {
@HiveField(0)
final String messageID;
@ -33,7 +33,7 @@ class Message extends HiveObject implements FieldDebuggable {
@HiveField(21)
final bool trimmed;
Message({
SCNMessage({
required this.messageID,
required this.senderUserID,
required this.channelInternalName,
@ -49,8 +49,8 @@ class Message extends HiveObject implements FieldDebuggable {
required this.trimmed,
});
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
factory SCNMessage.fromJson(Map<String, dynamic> json) {
return SCNMessage(
messageID: json['message_id'] as String,
senderUserID: json['sender_user_id'] 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 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);
}

View File

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

View File

@ -63,6 +63,33 @@ class User {
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 {

View File

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

View File

@ -1,12 +1,15 @@
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:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.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/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key, required this.isVisiblePage});
@ -17,11 +20,13 @@ class ChannelRootPage extends StatefulWidget {
State<ChannelRootPage> createState() => _ChannelRootPageState();
}
class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false;
bool _reloadEnqueued = false;
@override
void initState() {
super.initState();
@ -31,10 +36,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
if (widget.isVisiblePage && !_isInitialized) _realInitState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose();
Navi.modalRouteObserver.unsubscribe(this);
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() {
ApplicationLog.debug('ChannelRootPage::_realInitState');
_pagingController.refresh();
@ -68,9 +98,9 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
}
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);
} catch (exc, trace) {
@ -94,13 +124,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
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) {
_pagingController.error = exc.toString();
setState(() {
_pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
} finally {
AppBarState().setLoadingIndeterminate(false);
@ -109,19 +143,35 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Channel>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Channel>(
itemBuilder: (context, item, index) => ChannelListItem(
channel: item,
onPressed: () {/*TODO*/},
return Scaffold(
body: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, ChannelWithSubscription>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
itemBuilder: (context, item, index) => ChannelListItem(
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/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.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/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class ChannelListItem extends StatefulWidget {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
@ -12,10 +20,12 @@ class ChannelListItem extends StatefulWidget {
const ChannelListItem({
required this.channel,
required this.onPressed,
required this.subscription,
super.key,
});
final Channel channel;
final Subscription? subscription;
final Null Function() onPressed;
@override
@ -23,7 +33,7 @@ class ChannelListItem extends StatefulWidget {
}
class _ChannelListItemState extends State<ChannelListItem> {
Message? lastMessage;
SCNMessage? lastMessage;
@override
void initState() {
@ -32,8 +42,10 @@ class _ChannelListItemState extends State<ChannelListItem> {
final acc = Provider.of<AppAuth>(context, listen: false);
if (acc.isAuth()) {
lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() 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(() {
lastMessage = channelMessages.firstOrNull;
});
@ -49,39 +61,56 @@ class _ChannelListItemState extends State<ChannelListItem> {
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: [
Row(
children: [
Expanded(
child: Text(
widget.channel.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
_buildIcon(context),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
),
],
),
),
Text(
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()),
style: const TextStyle(fontSize: 14),
),
],
SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
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),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
lastMessage?.title ?? '...',
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(width: 4),
GestureDetector(
onTap: () {
Navi.push(context, () => ChannelMessageViewPage(channel: this.widget.channel));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
),
),
],
),
@ -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:simplecloudnotifier/utils/notifier.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
@ -52,6 +53,12 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
onPressed: _sendTokenToServer,
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(
title: 'Debug',
showSearch: false,
showDebug: false,
child: Column(
children: [
Padding(

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.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_sharedprefs.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
@ -36,7 +36,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
_buildSharedPrefCard(context),
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
_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<FBMessage>('scn-fb-messages'), 'scn-fb-messages'),
],
@ -71,7 +71,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () {
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box<FBMessage>(boxname)));
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: boxFunc()));
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -16,7 +16,6 @@ class DebugHiveBoxPage extends StatelessWidget {
return SCNScaffold(
title: 'Hive: ' + boxName,
showSearch: false,
showDebug: false,
child: ListView.separated(
itemCount: box.length,
itemBuilder: (context, listIndex) {
@ -24,8 +23,9 @@ class DebugHiveBoxPage extends StatelessWidget {
onTap: () {
Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
},
child: ListTile(
title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)),
child: Container(
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(
title: 'HiveEntry',
showSearch: false,
showDebug: false,
child: ListView.separated(
itemCount: fields.length,
itemBuilder: (context, listIndex) {
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)),
subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")),
);

View File

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

View File

@ -16,7 +16,6 @@ class DebugRequestViewPage extends StatelessWidget {
return SCNScaffold(
title: 'Request',
showSearch: false,
showDebug: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
@ -61,6 +60,7 @@ class DebugRequestViewPage extends StatelessWidget {
onPressed: () {
Clipboard.setData(new ClipboardData(text: title));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${title}\n================= [/CLIPBOARD] =================');
},
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:hive_flutter/hive_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.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/settings/app_settings.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/app_auth.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';
class MessageListPage extends StatefulWidget {
@ -17,28 +20,28 @@ class MessageListPage extends StatefulWidget {
final bool isVisiblePage;
//TODO reload on switch to tab
//TODO reload on app to foreground
@override
State<MessageListPage> createState() => _MessageListPageState();
}
class _MessageListPageState extends State<MessageListPage> with RouteAware {
static const _pageSize = 128;
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;
bool _isInitialized = false;
List<MessageFilterChiplet> _filterChiplets = [];
@override
void initState() {
super.initState();
AppEvents().subscribeFilterListener(_onAddFilter);
AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification);
_pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) _realInitState();
@ -64,18 +67,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
void _realInitState() {
ApplicationLog.debug('MessageListPage::_realInitState');
final chnCache = Hive.box<Channel>('scn-channel-cache');
final msgCache = Hive.box<Message>('scn-message-cache');
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
if (SCNDataCache().hasMessagesAndChannels()) {
// ==== 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();
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
_pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null);
_pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null);
_backgroundRefresh(true);
} else {
@ -95,6 +92,8 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override
void dispose() {
ApplicationLog.debug('MessageListPage::dispose');
AppEvents().unsubscribeFilterListener(_onAddFilter);
AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification);
Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose();
_lifecyleListener.dispose();
@ -108,17 +107,22 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override
void didPopNext() {
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
_backgroundRefresh(false);
if (AppSettings().backgroundRefreshMessageListOnPop) {
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
_backgroundRefresh(false);
}
}
void _onLifecycleResume() {
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
_backgroundRefresh(false);
if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume && widget.isVisiblePage) {
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
_backgroundRefresh(false);
}
}
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 MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
@ -132,12 +136,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
_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} ]');
@ -154,6 +158,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Future<void> _backgroundRefresh(bool fullReplaceState) async {
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)');
@ -167,12 +172,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
setState(() {
_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) {
// fully replace/reset state
@ -221,49 +226,106 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, Message>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
itemBuilder: (context, item, index) => MessageListItem(
message: item,
allChannels: _channels ?? {},
onPressed: () {
Navi.push(context, () => MessageViewPage(message: item));
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_filterChiplets.isNotEmpty)
Wrap(
alignment: WrapAlignment.start,
spacing: 5.0,
children: [
for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet),
],
),
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 {
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);
Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 2, 0, 2),
child: InputChip(
avatar: Icon(chiplet.icon()),
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 {
final cache = Hive.box<Message>('scn-message-cache');
void _onAddFilter(List<MessageFilterChipletType> remTypeList, List<MessageFilterChiplet> chiplets) {
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)) {
await cache.delete(val.messageID);
MessageFilter _getFilter() {
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:font_awesome_flutter/font_awesome_flutter.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:simplecloudnotifier/utils/ui.dart';
@ -18,7 +18,7 @@ class MessageListItem extends StatelessWidget {
super.key,
});
final Message message;
final SCNMessage message;
final Map<String, Channel> allChannels;
final Null Function() onPressed;
@ -176,11 +176,11 @@ class MessageListItem extends StatelessWidget {
return v;
}
String resolveChannelName(Message message) {
String resolveChannelName(SCNMessage message) {
return allChannels[message.channelID]?.displayName ?? message.channelInternalName;
}
bool showChannel(Message message) {
bool showChannel(SCNMessage message) {
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/models/channel.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/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_auth.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/ui.dart';
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
State<MessageViewPage> createState() => _MessageViewPageState();
}
class _MessageViewPageState extends State<MessageViewPage> {
late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
bool _monospaceMode = false;
SCNMessage? message = null;
@override
void initState() {
if (widget.preloadedData != null) {
message = widget.preloadedData!.$1;
}
mainFuture = fetchData();
super.initState();
}
Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
try {
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 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_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
@ -79,7 +92,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
showSearch: false,
showShare: true,
onShare: _share,
child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>(
child: FutureBuilder<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>(
future: mainFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
@ -87,8 +100,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
return _buildMessageView(context, msg, chn, tok, usr);
} else if (snapshot.hasError) {
return Center(child: Text('${snapshot.error}')); //TODO nice error page
} else if (!widget.message.trimmed) {
return _buildMessageView(context, widget.message, null, null, null);
} else if (message != null && !this.message!.trimmed) {
return _buildMessageView(context, this.message!, null, null, null);
} else {
return const Center(child: CircularProgressIndicator());
}
@ -98,7 +111,9 @@ class _MessageViewPageState extends State<MessageViewPage> {
}
void _share() async {
var msg = widget.message;
if (this.message == null) return;
var msg = this.message!;
if (mainFutureSnapshot != null) {
(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);
return SingleChildScrollView(
@ -131,12 +146,58 @@ class _MessageViewPageState extends State<MessageViewPage> {
SizedBox(height: 8),
if (message.content != null) ..._buildMessageContent(context, message),
SizedBox(height: 8),
if (message.senderName != null) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null),
_buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null),
_buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO
if (message.senderName != null)
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSignature,
title: 'Sender',
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]),
],
),
@ -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;
}
List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) {
List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) {
return [
Row(
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 [
Row(
children: [
@ -178,6 +239,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
onPressed: () {
Clipboard.setData(new ClipboardData(text: message.content ?? ''));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${message.content}\n================= [/CLIPBOARD] =================');
},
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) {
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) {
String _preformatTitle(SCNMessage message) {
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:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/token_source.dart';
@ -12,9 +15,9 @@ class AppAuth extends ChangeNotifier implements TokenSource {
String? _tokenAdmin;
String? _tokenSend;
User? _user;
Client? _client;
DateTime? _clientQueryTime;
(User, DateTime)? _user;
(Client, DateTime)? _client;
String? get userID => _userID;
String? get tokenAdmin => _tokenAdmin;
@ -35,17 +38,21 @@ class AppAuth extends ChangeNotifier implements TokenSource {
}
void set(User user, Client client, String tokenAdmin, String tokenSend) {
_client = client;
_user = user;
_client = (client, DateTime.now());
_user = (user, DateTime.now());
_userID = user.userID;
_clientID = client.clientID;
_tokenAdmin = tokenAdmin;
_tokenSend = tokenSend;
notifyListeners();
}
void setClientAndClientID(Client client) {
_client = client;
_client = (client, DateTime.now());
_clientID = client.clientID;
notifyListeners();
}
@ -83,6 +90,33 @@ class AppAuth extends ChangeNotifier implements TokenSource {
_client = 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();
}
@ -94,6 +128,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.remove('auth.tokensend');
await Globals().sharedPrefs.setString('auth.cdate', "");
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 {
await Globals().sharedPrefs.setString('auth.userid', _userID!);
await Globals().sharedPrefs.setString('auth.clientid', _clientID!);
@ -101,14 +139,34 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!);
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());
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());
}
Future<User> loadUser({bool force = false}) async {
if (!force && _user != null && _user!.userID == _userID) {
return _user!;
Future<User> loadUser({bool force = false, Duration? forceIfOlder = null}) async {
if (forceIfOlder != null && _user != null && _user!.$2.difference(DateTime.now()) > forceIfOlder) {
force = true;
}
if (!force && _user != null && _user!.$1.userID == _userID) {
return _user!.$1;
}
if (_userID == null || _tokenAdmin == null) {
@ -117,20 +175,24 @@ class AppAuth extends ChangeNotifier implements TokenSource {
final user = await APIClient.getUser(this, _userID!);
_user = user;
_user = (user, DateTime.now());
await save();
return user;
}
User? getUserOrNull() {
return _user?.$1;
}
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;
}
if (!force && _client != null && _client!.clientID == _clientID) {
return _client!;
if (!force && _client != null && _client!.$1.clientID == _clientID) {
return _client!.$1;
}
if (_clientID == null || _tokenAdmin == null) {
@ -140,7 +202,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
try {
final client = await APIClient.getClient(this, _clientID!);
_client = client;
_client = (client, DateTime.now());
await save();
@ -154,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
}
}
Client? getClientOrNull() {
return _client?.$1;
}
@override
String getToken() {
return _tokenAdmin!;

View File

@ -12,9 +12,18 @@ class AppBarState extends ChangeNotifier {
bool _loadingIndeterminate = false;
bool get loadingIndeterminate => _loadingIndeterminate;
bool _showSearchField = false;
bool get showSearchField => _showSearchField;
void setLoadingIndeterminate(bool v) {
if (_loadingIndeterminate == v) return;
_loadingIndeterminate = v;
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}) {
(additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@ -23,6 +24,7 @@ class ApplicationLog {
static void info(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@ -36,6 +38,7 @@ class ApplicationLog {
static void warn(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@ -49,6 +52,7 @@ class ApplicationLog {
static void error(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@ -62,6 +66,7 @@ class ApplicationLog {
static void fatal(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),

View File

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

View File

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

View File

@ -13,6 +13,8 @@ class Globals {
Globals._internal();
bool _initialized = false;
String appName = '';
String packageName = '';
String version = '';
@ -24,7 +26,11 @@ class Globals {
late SharedPreferences sharedPrefs;
bool get isInitialized => _initialized;
Future<void> init() async {
if (_initialized) return;
PackageInfo packageInfo = await PackageInfo.fromPlatform();
this.appName = packageInfo.appName;
@ -54,6 +60,8 @@ class Globals {
}
this.sharedPrefs = await SharedPreferences.getInstance();
this._initialized = true;
}
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
// 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> {
final Future<T> future;
final T? value;
T? _futureValue = null;
ImmediateFuture(this.future, this.value);
ImmediateFuture.ofFuture(Future<T> v)
: future = v,
value = null;
value = null {
future.then((v) => _futureValue = v);
}
ImmediateFuture.ofValue(T v)
: future = Future.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) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
}
static void popToRoot(BuildContext context) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.popUntil(context, (route) => route.isFirst);
}
static void popDialog(BuildContext dialogContext) {
Navigator.pop(dialogContext);
}
}
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPush(route, previousRoute);
if (route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPush()');
}
@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didReplace()');
}
@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute && route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
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,
);
}
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 firebase_core
import firebase_messaging
import flutter_local_notifications
import package_info_plus
import path_provider_foundation
import share_plus
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

View File

@ -209,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -342,6 +350,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -916,6 +948,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.1"
timezone:
dependency: transitive
description:
name: timezone
sha256: a6ccda4a69a442098b602c44e61a1e2b4bf6f5516e875bbf0f427d5df14745d5
url: "https://pub.dev"
source: hosted
version: "0.9.3"
timing:
dependency: transitive
description:

View File

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

View File

@ -5,7 +5,7 @@ PORT=9090
NAMESPACE=$(shell git rev-parse --abbrev-ref 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

View File

@ -418,6 +418,7 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
}
var u uri
@ -457,16 +458,16 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
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 {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
if trimmed {
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 {
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 {
PageSize *int `json:"page_size" form:"page_size"`
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"`
Channels []string `json:"channel" form:"channel"`
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
Priority []int `json:"priority" form:"priority"`
KeyTokens []string `json:"used_key" form:"used_key"`
HasSender *bool `json:"has_sender" form:"has_sender"`
}
type response struct {
Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
}
var q query
@ -90,8 +93,12 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
ConfirmedSubscriptionBy: langext.Ptr(userid),
}
if q.Filter != nil && strings.TrimSpace(*q.Filter) != "" {
filter.SearchString = langext.Ptr([]string{strings.TrimSpace(*q.Filter)})
if len(q.Search) != 0 {
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 {
@ -114,6 +121,10 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
filter.SenderNameCS = langext.Ptr(q.Senders)
}
if q.HasSender != nil {
filter.HasSenderName = langext.Ptr(*q.HasSender)
}
if q.TimeBefore != nil {
t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
if err != nil {
@ -146,17 +157,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
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 {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
if trimmed {
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 {
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()
adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, b.FCMToken)
err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}

View File

@ -9,7 +9,6 @@ import (
"bytes"
"errors"
"github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
@ -91,10 +90,9 @@ func (h CommonHandler) Ping(pctx ginext.PreContext) ginext.HTTPResponse {
// @Router /api/db-test [post]
func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
Success bool `json:"success"`
LibVersion string `json:"libVersion"`
LibVersionNumber int `json:"libVersionNumber"`
SourceID string `json:"sourceID"`
Success bool `json:"success"`
LibVersion string `json:"libVersion"`
SourceID string `json:"sourceID"`
}
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 {
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 {
return ginresp.InternalError(err)
}
return ginext.JSON(http.StatusOK, response{
Success: true,
LibVersion: libVersion,
LibVersionNumber: libVersionNumber,
SourceID: sourceID,
Success: true,
LibVersion: versionStr,
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 {
_, libVersionNumber, _ := sqlite3.Version()
if libVersionNumber < 3039000 {
return ginresp.InternalError(errors.New("sqlite version too low"))
}
tctx := simplectx.CreateSimpleContext(ctx, nil)
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()
err := h.database.ClearFCMTokens(ctx, *data.FCMToken)
err := h.database.DeleteClientsByFCM(ctx, *data.FCMToken)
if err != nil {
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),
}
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 {
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.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/:mid").Handle(r.apiHandler.GetMessage)
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/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)

View File

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

View File

@ -13,15 +13,21 @@ import (
"github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
name string
schemaVersion int
schema map[int]schema.Def
}
func NewLogsDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewLogsDatabase(cfg server.Config) (*Database, error) {
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
}
@ -99,52 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err
}
if currschema == 0 {
schemastr := schema.LogsSchema[schema.LogsSchemaVersion].SQL
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 == db.schemaVersion {
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
}
if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
for currschema < db.schemaVersion {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
if currschema == 0 {
log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.LogsSchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (logs db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (logs db)")
log.Debug().Str("schemaHashAsset", schema.LogsSchema[currschema].Hash).Msg("Schema (logs db)")
return errors.New("database schema does not match (logs db)")
schemastr := db.schema[db.schemaVersion].SQL
schemahash := db.schema[db.schemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
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 {
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))
}
@ -164,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
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 {
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) {
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
}
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) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
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) {
@ -60,7 +46,19 @@ func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid m
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,
"cid": clientid,
}, sq.SModeExtended, sq.Safe)
@ -72,7 +70,7 @@ func (db *Database) DeleteClient(ctx db.TxContext, clientid models.ClientID) 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 {
return err
}
@ -86,7 +84,7 @@ func (db *Database) DeleteClientsByFCM(ctx db.TxContext, fcmtoken string) error
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 {
return err
}
@ -100,7 +98,7 @@ func (db *Database) UpdateClientFCMToken(ctx db.TxContext, clientid models.Clien
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,
"cid": clientid,
})
@ -117,7 +115,7 @@ func (db *Database) UpdateClientAgentModel(ctx db.TxContext, clientid models.Cli
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,
"cid": clientid,
})
@ -134,7 +132,7 @@ func (db *Database) UpdateClientAgentVersion(ctx db.TxContext, clientid models.C
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,
"cid": clientid,
})
@ -151,7 +149,7 @@ func (db *Database) UpdateClientDescriptionName(ctx db.TxContext, clientid model
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,
"cid": clientid,
})

View File

@ -13,15 +13,21 @@ import (
"github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
name string
schemaVersion int
schema map[int]schema.Def
}
func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
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
}
@ -99,151 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err
}
if currschema == 0 {
schemastr := schema.PrimarySchema[schema.PrimarySchemaVersion].SQL
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 == db.schemaVersion {
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
}
if currschema == 1 {
return errors.New("cannot autom. upgrade schema 1")
}
for currschema < db.schemaVersion {
if currschema == 2 {
return errors.New("cannot autom. upgrade schema 2")
}
if currschema == 0 {
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")
if err != nil {
return err
}
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
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)")
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = db.schemaVersion
} 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{})
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)")
currschema = currschema + 1
}
}
if currschema != schema.PrimarySchemaVersion {
if currschema != db.schemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
@ -263,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
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 {
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) {
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{
"next": scn.NextDeliveryTimestamp(time.Now()),
"next": time2DB(scn.NextDeliveryTimestamp(time.Now())),
"rc": delivery.RetryCount + 1,
"did": delivery.DeliveryID,
})

View File

@ -80,14 +80,10 @@ func (db *Database) DeleteMessage(ctx db.TxContext, messageID models.MessageID)
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)
if err != nil {
return nil, ct.CursorToken{}, err
}
if inTok.Mode == ct.CTMEnd {
return make([]models.Message, 0), ct.End(), nil
return nil, ct.CursorToken{}, 0, err
}
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"
}
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["tokid"] = inTok.Id
data, err := sq.QueryAll[models.Message](ctx, tx, sqlQuery, prepParams, sq.SModeExtended, sq.Safe)
if err != nil {
return nil, ct.CursorToken{}, err
if inTok.Mode == ct.CTMEnd {
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 {
return data, ct.End(), nil
dataList, err := sq.QueryAll[models.Message](ctx, tx, sqlQueryList, prepParams, sq.SModeExtended, sq.Safe)
if err != nil {
return nil, ct.CursorToken{}, 0, err
}
if pageSize == nil || len(dataList) <= *pageSize {
return dataList, ct.End(), int64(len(dataList)), nil
} 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)
}
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 {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {

View File

@ -23,3 +23,7 @@ func time2DBOpt(t *time.Time) *int64 {
}
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/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"os"
"path/filepath"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
name string
schemaVersion int
schema map[int]schema.Def
}
func NewRequestsDatabase(cfg server.Config) (*Database, error) {
@ -66,7 +72,14 @@ func NewRequestsDatabase(cfg server.Config) (*Database, error) {
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
}
@ -99,57 +112,49 @@ func (db *Database) Migrate(outerctx context.Context) error {
return err
}
if currschema == 0 {
schemastr := schema.RequestsSchema[schema.RequestsSchemaVersion].SQL
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 == db.schemaVersion {
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
}
if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
for currschema < db.schemaVersion {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
if currschema == 0 {
log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.RequestsSchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (requests db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (requests db)")
log.Debug().Str("schemaHashAsset", schema.RequestsSchema[currschema].Hash).Msg("Schema (requests db)")
return errors.New("database schema does not match (requests db)")
schemastr := db.schema[db.schemaVersion].SQL
schemahash := db.schema[db.schemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
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 {
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))
}
@ -169,10 +174,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
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 {
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) {
return db.db.BeginTransaction(ctx, sql.LevelDefault)
}

View File

@ -3,8 +3,9 @@ package schema
import _ "embed"
type Def struct {
SQL string
Hash string
SQL string
Hash string
MigScript string
}
//go:embed primary_1.ddl
@ -22,11 +23,29 @@ var primarySchema4 string
//go:embed primary_5.ddl
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
var PrimaryMigration_3_4 string
var primaryMigration_3_4 string
//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
var requestsSchema1 string
@ -35,26 +54,29 @@ var requestsSchema1 string
var logsSchema1 string
var PrimarySchema = map[int]Def{
0: {"", ""},
1: {primarySchema1, "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2"},
2: {primarySchema2, "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a"},
3: {primarySchema3, "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8"},
4: {primarySchema4, "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca"},
5: {primarySchema5, "9d6217ba4a3503cfe090f72569367f95a413bb14e9effe49ffeabbf255bce8dd"},
0: {"", "", ""},
1: {primarySchema1, "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2", ""},
2: {primarySchema2, "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a", ""},
3: {primarySchema3, "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8", ""},
4: {primarySchema4, "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca", primaryMigration_3_4},
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{
0: {"", ""},
1: {requestsSchema1, "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9"},
0: {"", "", ""},
1: {requestsSchema1, "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9", ""},
}
var RequestsSchemaVersion = 1
var RequestsSchemaVersion = len(RequestsSchema) - 1
var LogsSchema = map[int]Def{
0: {"", ""},
1: {logsSchema1, "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7"},
0: {"", "", ""},
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
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-sql-driver/mysql v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/rs/zerolog v1.33.0
github.com/viney-shih/go-lock v1.1.2
gogs.mikescher.com/BlackForestBytes/goext v0.0.513
gopkg.in/loremipsum.v1 v1.1.2
)
@ -44,7 +44,6 @@ require (
github.com/rs/xid v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/scram v1.1.2 // 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=
go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8=
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/go.mod h1:9Q9EjraeE3yih7EXgBlnwLLJXWuRZNsl7s5TVTh3aOU=
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) {
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 {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client")
ctx.RollbackTransaction()
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)
if err != nil {
@ -148,25 +166,6 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
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 {
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
@ -174,9 +173,68 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
ctx.RollbackTransaction()
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 {
err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID)
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"
"context"
"errors"
"fmt"
"github.com/rs/zerolog/log"
golock "github.com/viney-shih/go-lock"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"net"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"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 {
Config scn.Config
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) {
fcmDelivID, err := app.Pusher.SendNotification(ctx, user, client, channel, msg)
fcmDelivID, errCode, err := app.Pusher.SendNotification(ctx, user, client, channel, msg)
if err != nil {
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 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))
}
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 {
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 {
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)
if err != nil {
_, err = app.Database.Primary.CreateRetryDelivery(ctx, client, msg)

View File

@ -9,7 +9,7 @@ import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3"
"github.com/glebarez/go-sqlite"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"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 {
orig := exerr.OriginalError(errwrap.Unwrap())
var sqlite3Err sqlite3.Error
if errors.As(orig, &sqlite3Err) {
if sqlite3Err.Code == 5 { // [5] == SQLITE_BUSY
var sqliteErr *sqlite.Error
if errors.As(orig, &sqliteErr) {
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
}
}

View File

@ -3,9 +3,9 @@ package models
type Channel struct {
ChannelID ChannelID `db:"channel_id" json:"channel_id"`
OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"`
InternalName string `db:"internal_name" json:"internal_name"`
DisplayName string `db:"display_name" json:"display_name"`
DescriptionName *string `db:"description_name" json:"description_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, used for display purposes, can be changed, initially equals InternalName
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
TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"`
TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"`

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