new push system does work on android #53

This commit is contained in:
otsmr 2025-04-06 15:38:15 +02:00
parent 773f076abd
commit 85dbac37fb
16 changed files with 601 additions and 122 deletions

View file

@ -41,6 +41,7 @@ void main() async {
apiProvider = ApiProvider(); apiProvider = ApiProvider();
twonlyDatabase = TwonlyDatabase(); twonlyDatabase = TwonlyDatabase();
setupNotificationWithUsers();
runApp( runApp(
MultiProvider( MultiProvider(

View file

@ -11,8 +11,7 @@ enum MessageKind {
acceptRequest, acceptRequest,
opened, opened,
ack, ack,
pushKey, pushKey
pushKeyAck,
} }
enum DownloadState { enum DownloadState {

View file

@ -88,6 +88,8 @@ class MessageContent {
return ProfileContent.fromJson(json); return ProfileContent.fromJson(json);
case MessageKind.storedMediaFile: case MessageKind.storedMediaFile:
return StoredMediaFileContent.fromJson(json); return StoredMediaFileContent.fromJson(json);
case MessageKind.pushKey:
return PushKeyContent.fromJson(json);
default: default:
return null; return null;
} }
@ -203,3 +205,24 @@ class ProfileContent extends MessageContent {
return {'avatarSvg': avatarSvg, 'displayName': displayName}; return {'avatarSvg': avatarSvg, 'displayName': displayName};
} }
} }
class PushKeyContent extends MessageContent {
int keyId;
List<int> key;
PushKeyContent({required this.keyId, required this.key});
static PushKeyContent fromJson(Map json) {
return PushKeyContent(
keyId: json['keyId'],
key: List<int>.from(json['key']),
);
}
@override
Map toJson() {
return {
'keyId': keyId,
'key': key,
};
}
}

View file

@ -65,6 +65,10 @@
"settingsPrivacyBlockUsersDesc": "Blockierte Benutzer können nicht mit dir kommunizieren. Du kannst einen blockierten Benutzer jederzeit wieder entsperren.", "settingsPrivacyBlockUsersDesc": "Blockierte Benutzer können nicht mit dir kommunizieren. Du kannst einen blockierten Benutzer jederzeit wieder entsperren.",
"settingsPrivacyBlockUsersCount": "{len} Kontakt(e)", "settingsPrivacyBlockUsersCount": "{len} Kontakt(e)",
"settingsNotification": "Benachrichtigung", "settingsNotification": "Benachrichtigung",
"settingsNotifyTroubleshooting": "Fehlersuche",
"settingsNotifyTroubleshootingDesc": "Hier klicken, wenn Probleme beim Empfang von Push-Benachrichtigungen auftreten.",
"settingsNotifyTroubleshootingNoProblem": "Kein Problem festgestellt",
"settingsNotifyTroubleshootingNoProblemDesc": "Klicke auf OK, um eine Testbenachrichtigung zu erhalten. Wenn du auch nach 10 Minuten warten keine Nachricht erhältst, sende uns bitte dein Diagnoseprotokoll unter Einstellungen > Hilfe > Diagnoseprotokoll, damit wir uns das Problem ansehen können.",
"settingsHelp": "Hilfe", "settingsHelp": "Hilfe",
"settingsHelpSupport": "Support-Center", "settingsHelpSupport": "Support-Center",
"settingsHelpDiagnostics": "Diagnoseprotokoll", "settingsHelpDiagnostics": "Diagnoseprotokoll",

View file

@ -138,6 +138,14 @@
}, },
"settingsNotification": "Notification", "settingsNotification": "Notification",
"@settingsNotification": {}, "@settingsNotification": {},
"settingsNotifyTroubleshooting": "Troubleshooting",
"@settingsNotifyTroubleshooting": {},
"settingsNotifyTroubleshootingDesc": "Click here if you have problems receiving push notifications.",
"@settingsNotifyTroubleshootingDesc": {},
"settingsNotifyTroubleshootingNoProblem": "No problem detected",
"@settingsNotifyTroubleshootingNoProblem": {},
"settingsNotifyTroubleshootingNoProblemDesc": "Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.",
"@settingsNotifyTroubleshootingNoProblemDesc": {},
"settingsHelp": "Help", "settingsHelp": "Help",
"@settingsHelp": {}, "@settingsHelp": {},
"settingsHelpDiagnostics": "Diagnostic protocol", "settingsHelpDiagnostics": "Diagnostic protocol",

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/json_models/userdata.dart';
import 'package:twonly/src/proto/api/error.pb.dart'; import 'package:twonly/src/proto/api/error.pb.dart';
import 'package:twonly/src/providers/api/api_utils.dart'; import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/providers/hive.dart'; import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/services/notification_service.dart';
// ignore: library_prefixes // ignore: library_prefixes
import 'package:twonly/src/utils/signal.dart' as SignalHelper; import 'package:twonly/src/utils/signal.dart' as SignalHelper;
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
@ -31,7 +32,9 @@ Future tryTransmitMessages() async {
Result resp = await apiProvider.sendTextMessage( Result resp = await apiProvider.sendTextMessage(
msg.userId, msg.userId,
msg.bytes, msg.bytes,
msg.pushData,
); );
if (resp.isSuccess) { if (resp.isSuccess) {
if (msg.messageId != null) { if (msg.messageId != null) {
await twonlyDatabase.messagesDao.updateMessageByMessageId( await twonlyDatabase.messagesDao.updateMessageByMessageId(
@ -53,8 +56,13 @@ class RetransmitMessage {
int? messageId; int? messageId;
int userId; int userId;
Uint8List bytes; Uint8List bytes;
RetransmitMessage( List<int>? pushData;
{this.messageId, required this.userId, required this.bytes}); RetransmitMessage({
this.messageId,
required this.userId,
required this.bytes,
this.pushData,
});
// From JSON constructor // From JSON constructor
factory RetransmitMessage.fromJson(Map<String, dynamic> json) { factory RetransmitMessage.fromJson(Map<String, dynamic> json) {
@ -62,6 +70,7 @@ class RetransmitMessage {
messageId: json['messageId'], messageId: json['messageId'],
userId: json['userId'], userId: json['userId'],
bytes: base64Decode(json['bytes']), bytes: base64Decode(json['bytes']),
pushData: json['pushData'],
); );
} }
@ -71,6 +80,7 @@ class RetransmitMessage {
'messageId': messageId, 'messageId': messageId,
'userId': userId, 'userId': userId,
'bytes': base64Encode(bytes), 'bytes': base64Encode(bytes),
'pushData': pushData,
}; };
} }
} }
@ -88,7 +98,8 @@ Future<Map<String, dynamic>> getAllMessagesForRetransmitting() async {
// this functions ensures that the message is received by the server and in case of errors will try again later // this functions ensures that the message is received by the server and in case of errors will try again later
Future<Result> encryptAndSendMessage( Future<Result> encryptAndSendMessage(
int? messageId, int userId, MessageJson msg) async { int? messageId, int userId, MessageJson msg,
{PushKind? pushKind}) async {
Uint8List? bytes = await SignalHelper.encryptMessage(msg, userId); Uint8List? bytes = await SignalHelper.encryptMessage(msg, userId);
if (bytes == null) { if (bytes == null) {
@ -99,6 +110,11 @@ Future<Result> encryptAndSendMessage(
String stateId = (messageId ?? (60001 + Random().nextInt(100000))).toString(); String stateId = (messageId ?? (60001 + Random().nextInt(100000))).toString();
Box box = await getMediaStorage(); Box box = await getMediaStorage();
List<int>? pushData;
if (pushKind != null) {
pushData = await getPushData(userId, pushKind);
}
{ {
var retransmit = await getAllMessagesForRetransmitting(); var retransmit = await getAllMessagesForRetransmitting();
@ -106,12 +122,13 @@ Future<Result> encryptAndSendMessage(
messageId: messageId, messageId: messageId,
userId: userId, userId: userId,
bytes: bytes, bytes: bytes,
pushData: pushData,
).toJson()); ).toJson());
box.put("messages-to-retransmit", jsonEncode(retransmit)); box.put("messages-to-retransmit", jsonEncode(retransmit));
} }
Result resp = await apiProvider.sendTextMessage(userId, bytes); Result resp = await apiProvider.sendTextMessage(userId, bytes, pushData);
if (resp.isSuccess) { if (resp.isSuccess) {
if (messageId != null) { if (messageId != null) {
@ -133,7 +150,8 @@ Future<Result> encryptAndSendMessage(
return resp; return resp;
} }
Future sendTextMessage(int target, TextMessageContent content) async { Future sendTextMessage(
int target, TextMessageContent content, PushKind? pushKind) async {
DateTime messageSendAt = DateTime.now(); DateTime messageSendAt = DateTime.now();
int? messageId = await twonlyDatabase.messagesDao.insertMessage( int? messageId = await twonlyDatabase.messagesDao.insertMessage(
@ -158,7 +176,7 @@ Future sendTextMessage(int target, TextMessageContent content) async {
timestamp: messageSendAt, timestamp: messageSendAt,
); );
encryptAndSendMessage(messageId, target, msg); encryptAndSendMessage(messageId, target, msg, pushKind: pushKind);
} }
Future notifyContactAboutOpeningMessage( Future notifyContactAboutOpeningMessage(

View file

@ -12,6 +12,7 @@ import 'package:twonly/src/proto/api/server_to_client.pb.dart';
import 'package:twonly/src/providers/api/api.dart'; import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/api_utils.dart'; import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/providers/hive.dart'; import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
Future tryDownloadAllMediaFiles() async { Future tryDownloadAllMediaFiles() async {
@ -280,6 +281,7 @@ class ImageUploader {
), ),
timestamp: metadata.messageSendAt, timestamp: metadata.messageSendAt,
), ),
pushKind: PushKind.image,
); );
} }
} }

View file

@ -190,7 +190,6 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
case MessageKind.acceptRequest: case MessageKind.acceptRequest:
final update = ContactsCompanion(accepted: Value(true)); final update = ContactsCompanion(accepted: Value(true));
await twonlyDatabase.contactsDao.updateContact(fromUserId, update); await twonlyDatabase.contactsDao.updateContact(fromUserId, update);
localPushNotificationNewMessage(fromUserId.toInt(), message, 8888888);
notifyContactsAboutProfileChange(); notifyContactsAboutProfileChange();
break; break;
@ -214,6 +213,14 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
); );
break; break;
case MessageKind.pushKey:
if (message.content != null) {
final pushKey = message.content!;
if (pushKey is PushKeyContent) {
await handleNewPushKey(fromUserId, pushKey);
}
}
default: default:
if (message.kind != MessageKind.textMessage && if (message.kind != MessageKind.textMessage &&
message.kind != MessageKind.media && message.kind != MessageKind.media &&
@ -299,7 +306,6 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
} }
} }
} }
localPushNotificationNewMessage(fromUserId, message, messageId);
} }
} }
var ok = client.Response_Ok()..none = true; var ok = client.Response_Ok()..none = true;
@ -328,21 +334,13 @@ Future<client.Response> handleContactRequest(
if (username.isSuccess) { if (username.isSuccess) {
Uint8List name = username.value.userdata.username; Uint8List name = username.value.userdata.username;
int added = await twonlyDatabase.contactsDao.insertContact( await twonlyDatabase.contactsDao.insertContact(
ContactsCompanion( ContactsCompanion(
username: Value(utf8.decode(name)), username: Value(utf8.decode(name)),
userId: Value(fromUserId), userId: Value(fromUserId),
requested: Value(true), requested: Value(true),
), ),
); );
if (added > 0) {
localPushNotificationNewMessage(
fromUserId,
message,
999999,
);
}
} }
var ok = client.Response_Ok()..none = true; var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok; return client.Response()..ok = ok;

View file

@ -385,11 +385,16 @@ class ApiProvider {
return await sendRequestSync(req); return await sendRequestSync(req);
} }
Future<Result> sendTextMessage(int target, Uint8List msg) async { Future<Result> sendTextMessage(
int target, Uint8List msg, List<int>? pushData) async {
var testMessage = ApplicationData_TextMessage() var testMessage = ApplicationData_TextMessage()
..userId = Int64(target) ..userId = Int64(target)
..body = msg; ..body = msg;
if (pushData != null) {
testMessage.pushData = pushData;
}
var appData = ApplicationData()..textmessage = testMessage; var appData = ApplicationData()..textmessage = testMessage;
var req = createClientToServerFromApplicationData(appData); var req = createClientToServerFromApplicationData(appData);

View file

@ -3,8 +3,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/app.dart'; import 'package:twonly/src/app.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/providers/api_provider.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../../firebase_options.dart'; import '../../firebase_options.dart';
@ -70,38 +69,38 @@ Future initFCMService() async {
} }
} }
// APNS token is available, make FCM plugin API requests...
FirebaseMessaging.onMessage.listen((RemoteMessage message) { FirebaseMessaging.onMessage.listen((RemoteMessage message) {
print('Got a message whilst in the foreground!'); if (!Platform.isAndroid) {
Logger("firebase-notification").shout("Got message in Dart while on iOS");
}
Logger("firebase-notification")
.finer('Got a message while in the foreground!');
print('Message data: ${message.data}'); print('Message data: ${message.data}');
if (message.notification != null) { if (message.notification != null) {
print('Message also contained a notification: ${message.notification}'); print('Message also contained a notification: ${message.notification}');
String title = message.notification!.title ?? "";
String body = message.notification!.body ?? "";
customLocalPushNotification(title, body);
}
if (message.data["push_data"] != null) {
handlePushData(message.data["push_data"]);
} }
}); });
} }
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Wenn Tasks länger als 30 Sekunden ausgeführt werden, wird der Prozess möglicherweise automatisch vom Gerät beendet.
// -> offer backend via http?
Logger("firebase-background") Logger("firebase-background")
.shout('Handling a background message: ${message.messageId}'); .shout('Handling a background message: ${message.messageId}');
twonlyDatabase = TwonlyDatabase(); if (!Platform.isAndroid) {
Logger("firebase-notification").shout("Got message in Dart while on iOS");
apiProvider = ApiProvider();
await apiProvider.connect();
final stopwatch = Stopwatch()..start();
while (true) {
if (stopwatch.elapsed >= Duration(seconds: 20)) {
Logger("firebase-background").shout('Exiting background handler');
break;
} }
await Future.delayed(Duration(milliseconds: 10));
} Logger("firebase-notification")
await apiProvider.close(() {}); .finer('Got a message while in the background!');
print('Message data: ${message.data}');
} }

View file

@ -1,13 +1,349 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_svg/svg.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/json_models/message.dart' as my; import 'package:twonly/src/json_models/message.dart' as my;
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/utils/misc.dart';
class PushUser {
String displayName;
List<PushKeyMeta> keys;
PushUser({
required this.displayName,
required this.keys,
});
// Factory method to create a User from JSON
factory PushUser.fromJson(Map<String, dynamic> json) {
return PushUser(
displayName: json['displayName'],
keys: (json['keys'] as List)
.map((keyJson) => PushKeyMeta.fromJson(keyJson))
.toList(),
);
}
// Method to convert User to JSON
Map<String, dynamic> toJson() {
return {
'displayName': displayName,
'keys': keys.map((key) => key.toJson()).toList(),
};
}
}
class PushKeyMeta {
int id;
List<int> key;
DateTime createdAt;
PushKeyMeta({
required this.id,
required this.key,
required this.createdAt,
});
// Factory method to create Keys from JSON
factory PushKeyMeta.fromJson(Map<String, dynamic> json) {
return PushKeyMeta(
id: json['id'],
key: List<int>.from(json['key']),
createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt']),
);
}
// Method to convert Keys to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'key': key,
'createdAt': createdAt.millisecondsSinceEpoch, // Store as timestamp
};
}
}
/// This function must be called after the database is setup
Future setupNotificationWithUsers({bool force = false}) async {
var pushKeys = await getPushKeys("receivingPushKeys");
var wasChanged = false;
final random = Random.secure();
final contacts = await twonlyDatabase.contactsDao.getAllNotBlockedContacts();
for (final contact in contacts) {
if (pushKeys.containsKey(contact.userId)) {
// make it harder to predict the change of the key
final timeBefore =
DateTime.now().subtract(Duration(days: 5 + random.nextInt(5)));
final lastKey = pushKeys[contact.userId]!.keys.last;
if (force || lastKey.createdAt.isBefore(timeBefore)) {
final pushKey = PushKeyMeta(
id: lastKey.id + 1,
key: List<int>.generate(32, (index) => random.nextInt(256)),
createdAt: DateTime.now(),
);
sendNewPushKey(contact.userId, pushKey);
pushKeys[contact.userId]!.keys.add(pushKey);
pushKeys[contact.userId]!.displayName = getContactDisplayName(contact);
wasChanged = true;
}
} else {
/// Insert a new pushuser
final pushKey = PushKeyMeta(
id: 1,
key: List<int>.generate(32, (index) => random.nextInt(256)),
createdAt: DateTime.now(),
);
sendNewPushKey(contact.userId, pushKey);
final pushUser = PushUser(
displayName: getContactDisplayName(contact),
keys: [pushKey],
);
pushKeys[contact.userId] = pushUser;
wasChanged = true;
}
}
if (wasChanged) {
await setPushKeys("receivingPushKeys", pushKeys);
}
}
Future sendNewPushKey(int userId, PushKeyMeta pushKey) async {
await encryptAndSendMessage(
null,
userId,
my.MessageJson(
kind: MessageKind.pushKey,
content: my.PushKeyContent(keyId: pushKey.id, key: pushKey.key),
timestamp: pushKey.createdAt,
),
);
}
Future handleNewPushKey(int fromUserId, my.PushKeyContent pushKey) async {
var pushKeys = await getPushKeys("sendingPushKeys");
if (pushKeys[fromUserId] == null) {
pushKeys[fromUserId] = PushUser(displayName: "-", keys: []);
}
// only store the newest key...
pushKeys[fromUserId]!.keys = [
PushKeyMeta(
id: pushKey.keyId,
key: pushKey.key,
createdAt: DateTime.now(),
),
];
await setPushKeys("sendingPushKeys", pushKeys);
}
enum PushKind {
reaction,
text,
video,
twonly,
image,
contactRequest,
acceptRequest,
storedMediaFile,
testNotification
}
extension PushKindExtension on PushKind {
String get name => toString().split('.').last;
static PushKind fromString(String name) {
return PushKind.values.firstWhere((e) => e.name == name);
}
}
class PushNotification {
final int keyId;
final List<int> nonce;
final List<int> cipherText;
final List<int> mac;
PushNotification({
required this.keyId,
required this.nonce,
required this.cipherText,
required this.mac,
});
// Convert a PushNotification instance to a Map
Map<String, dynamic> toJson() {
return {
'keyId': keyId,
'nonce': base64Encode(nonce),
'cipherText': base64Encode(cipherText),
'mac': base64Encode(mac),
};
}
// Create a PushNotification instance from a Map
factory PushNotification.fromJson(Map<String, dynamic> json) {
return PushNotification(
keyId: json['keyId'],
nonce: base64Decode(json['nonce']),
cipherText: base64Decode(json['cipherText']),
mac: base64Decode(json['mac']),
);
}
}
/// this will trigger a push notification
/// push notification only containing the message kind and username
Future<List<int>?> getPushData(int toUserId, PushKind kind) async {
final Map<int, PushUser> pushKeys = await getPushKeys("sendingPushKeys");
List<int> key = "InsecureOnlyUsedForAddingContact".codeUnits;
int keyId = 0;
if (pushKeys[toUserId] == null) {
// user does not have send any push keys
// only allow accept request and contactrequest to be send in an insecure way :/
// In future find a better way, e.g. use the signal protocol in a native way..
if (kind != PushKind.acceptRequest &&
kind != PushKind.contactRequest &&
kind != PushKind.testNotification) {
// this will be enforced after every app uses this system... :/
// return null;
Logger("notification_service").shout(
"Using insecure key as the receiver does not send a push key!");
}
} else {
try {
key = pushKeys[toUserId]!.keys.last.key;
keyId = pushKeys[toUserId]!.keys.last.id;
} catch (e) {
Logger("notification_service")
.shout("No push notification key found for user $toUserId");
return null;
}
}
final chacha20 = Chacha20.poly1305Aead();
final nonce = chacha20.newNonce();
final secretBox = await chacha20.encrypt(
kind.name.codeUnits,
secretKey: SecretKeyData(key),
nonce: nonce,
);
final res = PushNotification(
keyId: keyId,
nonce: nonce,
cipherText: secretBox.cipherText,
mac: secretBox.mac.bytes,
);
return jsonEncode(res.toJson()).codeUnits;
}
Future<PushKind?> tryDecryptMessage(
List<int> key, PushNotification noti) async {
try {
final chacha20 = Chacha20.poly1305Aead();
SecretKeyData secretKeyData = SecretKeyData(key);
SecretBox secretBox = SecretBox(
noti.cipherText,
nonce: noti.nonce,
mac: Mac(noti.mac),
);
final plaintext =
await chacha20.decrypt(secretBox, secretKey: secretKeyData);
final plaintextString = utf8.decode(plaintext);
return PushKindExtension.fromString(plaintextString);
} catch (e) {
Logger("notification-service").shout(e);
return null;
}
}
Future handlePushData(String pushDataJson) async {
try {
String jsonString = utf8.decode(base64.decode(pushDataJson));
final pushData = PushNotification.fromJson(jsonDecode(jsonString));
PushKind? pushKind;
int? fromUserId;
if (pushData.keyId == 0) {
List<int> key = "InsecureOnlyUsedForAddingContact".codeUnits;
pushKind = await tryDecryptMessage(key, pushData);
} else {
var pushKeys = await getPushKeys("receivingPushKeys");
for (final userId in pushKeys.keys) {
for (final key in pushKeys[userId]!.keys) {
if (key.id == pushData.keyId) {
pushKind = await tryDecryptMessage(key.key, pushData);
if (pushKind != null) {
fromUserId = userId;
break;
}
}
}
// found correct key and user
if (fromUserId != null) break;
}
}
if (pushKind != null) {
if (pushKind == PushKind.testNotification) {
customLocalPushNotification(
"Test notification", "This is a test notification.");
} else if (fromUserId != null) {
showLocalPushNotification(fromUserId, pushKind);
}
}
} catch (e) {
Logger("notification-service").shout(e);
}
}
Future<Map<int, PushUser>> getPushKeys(String storageKey) async {
var storage = getSecureStorage();
String? pushKeysJson = await storage.read(key: storageKey);
Map<int, PushUser> pushKeys = <int, PushUser>{};
if (pushKeysJson != null) {
Map<String, dynamic> jsonMap = jsonDecode(pushKeysJson);
jsonMap.forEach((key, value) {
pushKeys[int.parse(key)] = PushUser.fromJson(value);
});
}
print("read: $storageKey: $pushKeys");
return pushKeys;
}
Future setPushKeys(String storageKey, Map<int, PushUser> pushKeys) async {
var storage = getSecureStorage();
Map<String, dynamic> jsonToSend = {};
pushKeys.forEach((key, value) {
jsonToSend[key.toString()] = value.toJson();
});
String jsonString = jsonEncode(jsonToSend);
print("write: $storageKey: $pushKeys");
await storage.write(key: storageKey, value: jsonString);
}
/// Streams are created so that app can respond to notification-related events /// Streams are created so that app can respond to notification-related events
/// since the plugin is initialized in the `main` function /// since the plugin is initialized in the `main` function
@ -96,102 +432,81 @@ Future<void> setupPushNotification() async {
); );
} }
String getPushNotificationText(String key, String userName) { Future<String?> getAvatarIcon(Contact user) async {
String systemLanguage = Platform.localeName; if (user.avatarSvg == null) return null;
Map<String, String> pushNotificationText; final PictureInfo pictureInfo =
await vg.loadPicture(SvgStringLoader(user.avatarSvg!), null);
if (systemLanguage.contains("de")) { final ui.Image image = await pictureInfo.picture.toImage(300, 300);
pushNotificationText = {
"newTextMessage": "%userName% hat dir eine Nachricht gesendet.", final ByteData? byteData =
"newTwonly": "%userName% hat dir ein twonly gesendet.", await image.toByteData(format: ui.ImageByteFormat.png);
"newVideo": "%userName% hat dir ein Video gesendet.", final Uint8List pngBytes = byteData!.buffer.asUint8List();
"newImage": "%userName% hat dir ein Bild gesendet.",
"contactRequest": "%userName% möchte sich mir dir vernetzen.", // Get the directory to save the image
"acceptRequest": "%userName% ist jetzt mit dir vernetzt.", final directory = await getApplicationDocumentsDirectory();
"storedMediaFile": "%userName% hat dein Bild gespeichert." final avatarsDirectory = Directory('${directory.path}/avatars');
};
} else { // Create the avatars directory if it does not exist
pushNotificationText = { if (!await avatarsDirectory.exists()) {
"newTextMessage": "%userName% has sent you a message.", await avatarsDirectory.create(recursive: true);
"newTwonly": "%userName% has sent you a twonly.",
"newVideo": "%userName% has sent you a video.",
"newImage": "%userName% has sent you an image.",
"contactRequest": "%userName% wants to connect with you.",
"acceptRequest": "%userName% is now connected with you.",
"storedMediaFile": "%userName% has stored your image."
};
} }
// Replace %userName% with the actual user name final filePath = '${avatarsDirectory.path}/${user.userId}.png';
return pushNotificationText[key]?.replaceAll("%userName%", userName) ?? ""; final file = File(filePath);
await file.writeAsBytes(pngBytes);
pictureInfo.picture.dispose();
return filePath;
} }
Future localPushNotificationNewMessage( Future showLocalPushNotification(
int fromUserId, my.MessageJson message, int messageId) async { int fromUserId,
PushKind pushKind,
) async {
String? title;
String? body;
Contact? user = await twonlyDatabase.contactsDao Contact? user = await twonlyDatabase.contactsDao
.getContactByUserId(fromUserId) .getContactByUserId(fromUserId)
.getSingleOrNull(); .getSingleOrNull();
if (user == null) return; if (user == null) return;
String msg = ""; title = getContactDisplayName(user);
body = getPushNotificationText(pushKind);
final content = message.content; if (body == "") {
if (content is my.TextMessageContent) {
msg =
getPushNotificationText("newTextMessage", getContactDisplayName(user));
} else if (content is my.MediaMessageContent) {
if (content.isRealTwonly) {
msg = getPushNotificationText("newTwonly", getContactDisplayName(user));
} else if (content.isVideo) {
msg = getPushNotificationText("newVideo", getContactDisplayName(user));
} else {
msg = getPushNotificationText("newImage", getContactDisplayName(user));
}
}
if (message.kind == MessageKind.contactRequest) {
msg =
getPushNotificationText("contactRequest", getContactDisplayName(user));
}
if (message.kind == MessageKind.acceptRequest) {
msg = getPushNotificationText("acceptRequest", getContactDisplayName(user));
}
if (message.kind == MessageKind.storedMediaFile) {
msg =
getPushNotificationText("storedMediaFile", getContactDisplayName(user));
}
if (msg == "") {
Logger("localPushNotificationNewMessage") Logger("localPushNotificationNewMessage")
.shout("No push notification type defined!"); .shout("No push notification type defined!");
} }
const AndroidNotificationDetails androidNotificationDetails = FilePathAndroidBitmap? styleInformation;
AndroidNotificationDetails( String? avatarPath = await getAvatarIcon(user);
'0', if (avatarPath != null) {
'Messages', styleInformation = FilePathAndroidBitmap(avatarPath);
}
AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails('0', 'Messages',
channelDescription: 'Messages from other users.', channelDescription: 'Messages from other users.',
importance: Importance.max, importance: Importance.max,
priority: Priority.max, priority: Priority.max,
ticker: 'You got a new message.', ticker: 'You got a new message.',
); largeIcon: styleInformation);
const DarwinNotificationDetails darwinNotificationDetails = const DarwinNotificationDetails darwinNotificationDetails =
DarwinNotificationDetails(); DarwinNotificationDetails();
const NotificationDetails notificationDetails = NotificationDetails( NotificationDetails notificationDetails = NotificationDetails(
android: androidNotificationDetails, iOS: darwinNotificationDetails); android: androidNotificationDetails, iOS: darwinNotificationDetails);
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
messageId, fromUserId,
getContactDisplayName(user), title,
msg, body,
notificationDetails, notificationDetails,
payload: message.kind.index.toString(), payload: pushKind.name,
); );
} }
@ -199,8 +514,8 @@ Future customLocalPushNotification(String title, String msg) async {
const AndroidNotificationDetails androidNotificationDetails = const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails( AndroidNotificationDetails(
'1', '1',
'Error', 'System',
channelDescription: 'Error messages.', channelDescription: 'System messages.',
importance: Importance.max, importance: Importance.max,
priority: Priority.max, priority: Priority.max,
); );
@ -211,9 +526,40 @@ Future customLocalPushNotification(String title, String msg) async {
android: androidNotificationDetails, iOS: darwinNotificationDetails); android: androidNotificationDetails, iOS: darwinNotificationDetails);
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
897898, 999999 + Random.secure().nextInt(9999),
title, title,
msg, msg,
notificationDetails, notificationDetails,
); );
} }
String getPushNotificationText(PushKind pushKind) {
String systemLanguage = Platform.localeName;
Map<String, String> pushNotificationText;
if (systemLanguage.contains("de")) {
pushNotificationText = {
PushKind.text.name: "hat dir eine Nachricht gesendet.",
PushKind.twonly.name: "hat dir ein twonly gesendet.",
PushKind.video.name: "hat dir ein Video gesendet.",
PushKind.image.name: "hat dir ein Bild gesendet.",
PushKind.contactRequest.name: "möchte sich mir dir vernetzen.",
PushKind.acceptRequest.name: "ist jetzt mit dir vernetzt.",
PushKind.storedMediaFile.name: "hat dein Bild gespeichert.",
PushKind.reaction.name: "hat auf dein Bild reagiert."
};
} else {
pushNotificationText = {
PushKind.text.name: "has sent you a message.",
PushKind.twonly.name: "has sent you a twonly.",
PushKind.video.name: "has sent you a video.",
PushKind.image.name: "has sent you an image.",
PushKind.contactRequest.name: "wants to connect with you.",
PushKind.acceptRequest.name: "is now connected with you.",
PushKind.storedMediaFile.name: "has stored your image.",
PushKind.reaction.name: "has reacted to your image."
};
}
return pushNotificationText[pushKind.name] ?? "";
}

View file

@ -280,6 +280,7 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
TextMessageContent( TextMessageContent(
text: newMessageController.text, text: newMessageController.text,
), ),
PushKind.text,
); );
newMessageController.clear(); newMessageController.clear();
currentInputText = ""; currentInputText = "";

View file

@ -323,6 +323,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
responseToMessageId: responseToMessageId:
allMediaFiles.first.messageOtherId, allMediaFiles.first.messageOtherId,
), ),
PushKind.reaction,
); );
setState(() { setState(() {
selectedShortReaction = index; selectedShortReaction = index;
@ -393,6 +394,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
timestamp: DateTime.now(), timestamp: DateTime.now(),
), ),
pushKind: PushKind.storedMediaFile,
); );
final res = await saveImageToGallery(imageBytes!); final res = await saveImageToGallery(imageBytes!);
if (res == null) { if (res == null) {

View file

@ -5,6 +5,7 @@ import 'package:twonly/src/components/alert_dialog.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/components/headline.dart'; import 'package:twonly/src/components/headline.dart';
@ -67,6 +68,7 @@ class _SearchUsernameView extends State<SearchUsernameView> {
timestamp: DateTime.now(), timestamp: DateTime.now(),
content: MessageContent(), content: MessageContent(),
), ),
pushKind: PushKind.contactRequest,
); );
} }
} }
@ -238,6 +240,7 @@ class _ContactsListViewState extends State<ContactsListView> {
timestamp: DateTime.now(), timestamp: DateTime.now(),
content: MessageContent(), content: MessageContent(),
), ),
pushKind: PushKind.acceptRequest,
); );
notifyContactsAboutProfileChange(); notifyContactsAboutProfileChange();
}, },

View file

@ -0,0 +1,64 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/components/alert_dialog.dart';
import 'package:twonly/src/services/fcm_service.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
class NotificationView extends StatelessWidget {
const NotificationView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsNotification),
),
body: ListView(
children: [
ListTile(
title: Text(context.lang.settingsNotifyTroubleshooting),
subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc),
onTap: () async {
await initFCMAfterAuthenticated();
final storage = getSecureStorage();
String? storedToken = await storage.read(key: "google_fcm");
//await setupNotificationWithUsers(force: true);
if (!context.mounted) return;
if (storedToken == null) {
final platform = Platform.isAndroid ? "Google's" : "Apple's";
showAlertDialog(context, "Problem detected",
"twonly is not able to register your app to $platform push server infrastrukture. For Android that can happen when you do not have the Google Play Services installed. If you theses installed and want to help us to fix the issue please send us your debug log in Settings > Help > Debug log.");
} else {
final run = await showAlertDialog(
context,
context.lang.settingsNotifyTroubleshootingNoProblem,
context.lang.settingsNotifyTroubleshootingNoProblemDesc);
if (run) {
final user = await getUser();
if (user != null) {
final pushData = await getPushData(
user.userId,
PushKind.testNotification,
);
await apiProvider.sendTextMessage(
user.userId,
Uint8List(0),
pushData,
);
}
}
}
},
),
],
),
);
}
}

View file

@ -7,6 +7,7 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/settings/account_view.dart'; import 'package:twonly/src/views/settings/account_view.dart';
import 'package:twonly/src/views/settings/appearance_view.dart'; import 'package:twonly/src/views/settings/appearance_view.dart';
import 'package:twonly/src/views/settings/notification_view.dart';
import 'package:twonly/src/views/settings/profile_view.dart'; import 'package:twonly/src/views/settings/profile_view.dart';
import 'package:twonly/src/views/settings/help_view.dart'; import 'package:twonly/src/views/settings/help_view.dart';
import 'package:twonly/src/views/settings/privacy_view.dart'; import 'package:twonly/src/views/settings/privacy_view.dart';
@ -129,11 +130,16 @@ class _SettingsMainViewState extends State<SettingsMainView> {
})); }));
}, },
), ),
// BetterListTile( BetterListTile(
// icon: FontAwesomeIcons.bell, icon: FontAwesomeIcons.bell,
// text: context.lang.settingsNotification, text: context.lang.settingsNotification,
// onTap: () async {}, onTap: () async {
// ), Navigator.push(context,
MaterialPageRoute(builder: (context) {
return NotificationView();
}));
},
),
const Divider(), const Divider(),
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.circleQuestion, icon: FontAwesomeIcons.circleQuestion,