Compare commits

..

No commits in common. "master" and "refactor_server" have entirely different histories.

124 changed files with 1038 additions and 5558 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 B

View File

@ -1,34 +0,0 @@
<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.

Before

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
class SCNAppBar extends StatefulWidget implements PreferredSizeWidget { class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
SCNAppBar({ const SCNAppBar({
Key? key, Key? key,
required this.title, required this.title,
required this.showThemeSwitch, required this.showThemeSwitch,
required this.showDebug,
required this.showSearch, required this.showSearch,
required this.showShare, required this.showShare,
this.onShare = null, this.onShare = null,
@ -23,33 +20,16 @@ class SCNAppBar extends StatefulWidget implements PreferredSizeWidget {
final String? title; final String? title;
final bool showThemeSwitch; final bool showThemeSwitch;
final bool showDebug;
final bool showSearch; final bool showSearch;
final bool showShare; final bool showShare;
final void Function()? onShare; final void Function()? onShare;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
State<SCNAppBar> createState() => _SCNAppBarState();
}
class _SCNAppBarState extends State<SCNAppBar> {
final TextEditingController _ctrlSearchField = TextEditingController();
@override
void dispose() {
_ctrlSearchField.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
var actions = <Widget>[]; var actions = <Widget>[];
if (cfg.showDebugButton) { if (showDebug) {
actions.add(IconButton( actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug', tooltip: 'Debug',
@ -59,7 +39,7 @@ class _SCNAppBarState extends State<SCNAppBar> {
)); ));
} }
if (widget.showThemeSwitch) { if (showThemeSwitch) {
actions.add(Consumer<AppTheme>( actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton( builder: (context, appTheme, child) => IconButton(
icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon),
@ -68,118 +48,54 @@ class _SCNAppBarState extends State<SCNAppBar> {
), ),
)); ));
} else { } else {
actions.add(_buildSpacer()); actions.add(Visibility(
}
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: () => AppBarState().setShowSearchField(true),
));
} else if (widget.showShare) {
actions.add(_buildSpacer());
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidShareNodes),
tooltip: 'Share',
onPressed: widget.onShare ?? () {},
));
} else {
actions.add(_buildSpacer());
actions.add(_buildSpacer());
}
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, visible: false,
maintainSize: true, maintainSize: true,
maintainAnimation: true, maintainAnimation: true,
maintainState: true, maintainState: true,
child: IconButton( child: IconButton(
icon: const Icon(FontAwesomeIcons.square), icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/* NO-OP */}, onPressed: () {/*TODO*/},
),
));
}
if (showSearch) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
tooltip: 'Search',
onPressed: () {/*TODO*/},
));
} else if (showShare) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidShareNodes),
tooltip: 'Share',
onPressed: onShare ?? () {},
));
} else {
actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
}
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(),
), ),
); );
} }
Widget _buildSearchTextField(BuildContext context) { @override
return TextField( Size get preferredSize => const Size.fromHeight(kToolbarHeight);
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

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

View File

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

View File

@ -1,114 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,67 +0,0 @@
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

@ -1,113 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class ChannelRootPage extends StatefulWidget { class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key, required this.isVisiblePage}); const ChannelRootPage({super.key, required this.isVisiblePage});
@ -20,13 +17,11 @@ class ChannelRootPage extends StatefulWidget {
State<ChannelRootPage> createState() => _ChannelRootPageState(); State<ChannelRootPage> createState() => _ChannelRootPageState();
} }
class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware { class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false; bool _isInitialized = false;
bool _reloadEnqueued = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -36,17 +31,10 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
if (widget.isVisiblePage && !_isInitialized) _realInitState(); if (widget.isVisiblePage && !_isInitialized) _realInitState();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
}
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose'); ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose(); _pagingController.dispose();
Navi.modalRouteObserver.unsubscribe(this);
super.dispose(); super.dispose();
} }
@ -63,24 +51,6 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
} }
} }
@override
void didPush() {
// ...
}
@override
void didPopNext() {
if (_reloadEnqueued) {
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
() async {
_reloadEnqueued = false;
AppBarState().setLoadingIndeterminate(true);
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
await _backgroundRefresh();
}();
}
}
void _realInitState() { void _realInitState() {
ApplicationLog.debug('ChannelRootPage::_realInitState'); ApplicationLog.debug('ChannelRootPage::_realInitState');
_pagingController.refresh(); _pagingController.refresh();
@ -98,9 +68,9 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
} }
try { try {
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) { } catch (exc, trace) {
@ -124,17 +94,13 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
AppBarState().setLoadingIndeterminate(true); AppBarState().setLoadingIndeterminate(true);
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
setState(() {
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
});
} catch (exc, trace) { } catch (exc, trace) {
setState(() {
_pagingController.error = exc.toString(); _pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
} finally { } finally {
AppBarState().setLoadingIndeterminate(false); AppBarState().setLoadingIndeterminate(false);
@ -143,35 +109,19 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => Future.sync( onRefresh: () => Future.sync(
() => _pagingController.refresh(), () => _pagingController.refresh(),
), ),
child: PagedListView<int, ChannelWithSubscription>( child: PagedListView<int, Channel>(
pagingController: _pagingController, pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>( builderDelegate: PagedChildBuilderDelegate<Channel>(
itemBuilder: (context, item, index) => ChannelListItem( itemBuilder: (context, item, index) => ChannelListItem(
channel: item.channel, channel: item,
subscription: item.subscription, onPressed: () {/*TODO*/},
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,18 +1,10 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class ChannelListItem extends StatefulWidget { class ChannelListItem extends StatefulWidget {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
@ -20,12 +12,10 @@ class ChannelListItem extends StatefulWidget {
const ChannelListItem({ const ChannelListItem({
required this.channel, required this.channel,
required this.onPressed, required this.onPressed,
required this.subscription,
super.key, super.key,
}); });
final Channel channel; final Channel channel;
final Subscription? subscription;
final Null Function() onPressed; final Null Function() onPressed;
@override @override
@ -33,7 +23,7 @@ class ChannelListItem extends StatefulWidget {
} }
class _ChannelListItemState extends State<ChannelListItem> { class _ChannelListItemState extends State<ChannelListItem> {
SCNMessage? lastMessage; Message? lastMessage;
@override @override
void initState() { void initState() {
@ -42,10 +32,8 @@ class _ChannelListItemState extends State<ChannelListItem> {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
if (acc.isAuth()) { if (acc.isAuth()) {
lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() async { () async {
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID])); final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]);
setState(() { setState(() {
lastMessage = channelMessages.firstOrNull; lastMessage = channelMessages.firstOrNull;
}); });
@ -61,14 +49,10 @@ class _ChannelListItemState extends State<ChannelListItem> {
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color, color: Theme.of(context).cardTheme.color,
child: InkWell( child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: widget.onPressed, onTap: widget.onPressed,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row(
children: [
_buildIcon(context),
SizedBox(width: 8),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -92,7 +76,7 @@ class _ChannelListItemState extends State<ChannelListItem> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
_preformatTitle(lastMessage), lastMessage?.title ?? '...',
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
), ),
), ),
@ -102,37 +86,7 @@ class _ChannelListItemState extends State<ChannelListItem> {
], ],
), ),
), ),
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),
),
),
],
),
),
), ),
); );
} }
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

@ -1,105 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,234 +0,0 @@
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

@ -1,238 +0,0 @@
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

@ -1,238 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,52 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@ -134,30 +134,12 @@ func (j *DeliveryRetryJob) execute() (fastrr bool, err error) {
func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery models.Delivery) { func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery models.Delivery) {
client, err := j.app.Database.Primary.GetClientOpt(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID) client, err := j.app.Database.Primary.GetClient(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID)
if err != nil { if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client") log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client")
ctx.RollbackTransaction() ctx.RollbackTransaction()
return return
} }
if client == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client (client no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [client==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
msg, err := j.app.Database.Primary.GetMessage(ctx, delivery.MessageID, true) msg, err := j.app.Database.Primary.GetMessage(ctx, delivery.MessageID, true)
if err != nil { if err != nil {
@ -166,6 +148,25 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
return return
} }
user, err := j.app.Database.Primary.GetUser(ctx, delivery.ReceiverUserID)
if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Msg("Failed to get user")
ctx.RollbackTransaction()
return
}
channel, err := j.app.Database.Primary.GetChannelByID(ctx, msg.ChannelID)
if err != nil {
log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if channel == nil {
log.Error().Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if msg.Deleted { if msg.Deleted {
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery) err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil { if err != nil {
@ -173,68 +174,9 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
ctx.RollbackTransaction() ctx.RollbackTransaction()
return return
} }
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [message.deleted] (set to FAILURE)") } else {
err = ctx.CommitTransaction() fcmDelivID, err := j.app.DeliverMessage(ctx, user, client, *channel, msg)
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
user, err := j.app.Database.Primary.GetUserOpt(ctx, delivery.ReceiverUserID)
if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Msg("Failed to get user")
ctx.RollbackTransaction()
return
}
if user == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get user (user no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [user==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
channel, err := j.app.Database.Primary.GetChannelByID(ctx, msg.ChannelID)
if err != nil {
log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction()
return
}
if channel == nil {
log.Error().Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel (client no longer exists)")
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed because of [channel==null] (set to FAILURE)")
err = ctx.CommitTransaction()
if err != nil {
log.Err(err).Msg("Failed to commit transaction")
return
}
return
}
{
fcmDelivID, err := j.app.DeliverMessage(ctx, *user, *client, *channel, msg)
if err == nil { if err == nil {
err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID) err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID)
if err != nil { if err != nil {
@ -259,11 +201,8 @@ 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,19 +9,25 @@ import (
"blackforestbytes.com/simplecloudnotifier/push" "blackforestbytes.com/simplecloudnotifier/push"
"context" "context"
"errors" "errors"
"fmt"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
golock "github.com/viney-shih/go-lock" golock "github.com/viney-shih/go-lock"
"gogs.mikescher.com/BlackForestBytes/goext/ginext" "gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext" "gogs.mikescher.com/BlackForestBytes/goext/syncext"
"net" "net"
"os" "os"
"os/signal" "os/signal"
"regexp"
"strings" "strings"
"syscall" "syscall"
"time" "time"
) )
var rexWhitespaceStart = rext.W(regexp.MustCompile("^\\s+"))
var rexWhitespaceEnd = rext.W(regexp.MustCompile("\\s+$"))
var rexNormalizeUsername = rext.W(regexp.MustCompile("[^[:alnum:]\\-_ ]"))
var rexCompatTitleChannel = rext.W(regexp.MustCompile("^\\[(?P<channel>[A-Za-z\\-0-9_ ]+)] (?P<title>(.|\\r|\\n)+)$"))
type Application struct { type Application struct {
Config scn.Config Config scn.Config
Gin *ginext.GinWrapper Gin *ginext.GinWrapper
@ -273,24 +279,9 @@ func (app *Application) NormalizeUsername(v string) string {
} }
func (app *Application) DeliverMessage(ctx context.Context, user models.User, client models.Client, channel models.Channel, msg models.Message) (string, error) { func (app *Application) DeliverMessage(ctx context.Context, user models.User, client models.Client, channel models.Channel, msg models.Message) (string, error) {
fcmDelivID, errCode, err := app.Pusher.SendNotification(ctx, user, client, channel, msg) fcmDelivID, err := app.Pusher.SendNotification(ctx, user, client, channel, msg)
if err != nil { if err != nil {
log.Warn().Str("MessageID", msg.MessageID.String()).Str("ClientID", client.ClientID.String()).Err(err).Msg("FCM Delivery failed") log.Warn().Str("MessageID", msg.MessageID.String()).Str("ClientID", client.ClientID.String()).Err(err).Msg("FCM Delivery failed")
if errCode == "UNREGISTERED" {
log.Warn().Msg(fmt.Sprintf("Auto-Delete client %s of user %s (FCM is UNREGISTERED)", client.ClientID, user.UserID))
_, _ = simplectx.Run(ctx, func(ctx db.TxContext) (any, error) {
err = app.Database.Primary.DeleteClient(ctx, client.ClientID)
if err != nil {
log.Err(err).Str("ClientID", client.ClientID.String()).Msg("Failed to delete client")
}
return nil, nil
})
}
return "", err return "", err
} }
return fcmDelivID, nil return fcmDelivID, nil

View File

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

View File

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

View File

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

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