~emersion/goguma-dev

Make building against Firebase optional v1 SUPERSEDED

delthas: 1
 Make building against Firebase optional

 9 files changed, 247 insertions(+), 185 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~emersion/goguma-dev/patches/31700/mbox | git am -3
Learn more about email & git

[PATCH] Make building against Firebase optional Export this patch

Also fixes a bug where the background firebase message handler would
fail because shared_preferences would not be properly initialized.
---
The huge diff is mainly just moving one file to another.
Whitespace differs so Git failed to pick this up.

 README.md                      |   6 +
 lib/client_controller.dart     |   7 +-
 lib/firebase.dart              | 190 +++----------------------------
 lib/firebase_impl.dart         | 197 +++++++++++++++++++++++++++++++++
 lib/main.dart                  |  10 +-
 lib/main_firebase.dart         |   8 ++
 pubspec.lock                   |   2 +-
 pubspec.yaml                   |   1 +
 tool/gen_firebase_options.dart |  11 +-
 9 files changed, 247 insertions(+), 185 deletions(-)
 create mode 100644 lib/firebase_impl.dart
 create mode 100644 lib/main_firebase.dart

diff --git a/README.md b/README.md
index 625e5f7..fe74577 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,12 @@ Build with:

The built APK is in `build/app/outputs/flutter-apk/app-release.apk`.

#### Firebase Cloud Messaging support

To support receiving real-time notifications in the background on Android, even when disconnected from IRC networks, goguma supports an optional Firebase Cloud Messaging build flavor. By default goguma does not link against Firebase.

    flutter build apk --flavor firebase --target lib/main_firebase.dart

## Contributing

Send patches to the [mailing list], report bugs on the [issue tracker]. Discuss
diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index da92f07..2fdc8a1 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:collection';
import 'dart:io';

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_background/flutter_background.dart';
import 'package:workmanager/workmanager.dart';
@@ -755,7 +754,8 @@ class ClientController {
	}

	void _setupPushSync() async {
		if (!Platform.isAndroid || !FirebaseMessaging.instance.isSupported()) {
		var supported = await firebaseProvider.isSupported();
		if (!supported) {
			return;
		}

@@ -767,7 +767,6 @@ class ClientController {
		print('Enabling push synchronization');

		var subs = await _db.listWebPushSubscriptions();
		var firebaseToken = await FirebaseMessaging.instance.getToken();
		var vapidKey = await client.webPushVapidPubKey();

		WebPushSubscription? oldSub;
@@ -791,7 +790,7 @@ class ClientController {
			await _db.deleteWebPushSubscription(oldSub.id!);
		}

		var endpoint = await createFirebaseSubscription(vapidKey);
		var endpoint = await firebaseProvider.createSubscription(vapidKey);
		var webPush = await WebPush.generate();
		var config = await webPush.exportPrivateKeys();
		var newSub = WebPushSubscription(
diff --git a/lib/firebase.dart b/lib/firebase.dart
index 5aa3168..0edbc67 100644
--- a/lib/firebase.dart
+++ b/lib/firebase.dart
@@ -1,183 +1,23 @@
import 'dart:convert' show json, utf8, base64;
import 'dart:io';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'database.dart';
import 'firebase_options.dart';
import 'irc.dart';
import 'models.dart';
import 'notification_controller.dart';
import 'webpush.dart';

final _gatewayEndpoint = Uri.parse('http://localhost:8082');

Future<String> createFirebaseSubscription(String vapidKey) async {
	var token = await FirebaseMessaging.instance.getToken();
	var client = HttpClient();
	try {
		var url = Uri.parse('http://10.0.2.2:8082/firebase/${firebaseOptions.projectId}/subscribe?token=$token');
		//var url = _gatewayEndpoint.resolve('/firebase/${firebaseOptions.projectId}/subscribe?token=$token');
		var req = await client.postUrl(url);
		req.headers.contentType = ContentType('application', 'webpush-options+json', charset: 'utf-8');
		req.write(json.encode({
			'vapid': vapidKey,
		}));
		var resp = await req.close();
		if (resp.statusCode ~/ 100 != 2) {
			throw Exception('HTTP error ${resp.statusCode}');
		}

		// TODO: parse subscription resource URL as well

		String? pushLink;
		for (var rawLink in resp.headers['Link'] ?? <String>[]) {
			var link = HeaderValue.parse(rawLink);
			if (link.parameters['rel'] == 'urn:ietf:params:push') {
				pushLink = link.value;
				break;
			}
		}

		if (pushLink == null || !pushLink.startsWith('<') || !pushLink.endsWith('>')) {
			throw FormatException('No valid urn:ietf:params:push Link found');
		}
		var pushUrl = pushLink.substring(1, pushLink.length - 1);
		return _gatewayEndpoint.resolve(pushUrl).toString();
	} finally {
		client.close();
	}
abstract class FirebaseProvider {
	Future<bool> isSupported();
	Future<String> createSubscription(String vapidKey);
	void initMessaging();
}

void initFirebaseMessaging() async {
	if (!Platform.isAndroid) {
		return;
	}

	await Firebase.initializeApp(options: firebaseOptions);

	if (!FirebaseMessaging.instance.isSupported()) {
		return;
	}

	FirebaseMessaging.onBackgroundMessage(_handleFirebaseMessage);
	FirebaseMessaging.onMessage.listen(_handleFirebaseMessage);
}

// This function may called from a separate Isolate
Future<void> _handleFirebaseMessage(RemoteMessage message) async {
	print('Received push message: ${message.data}');

	var encodedPayload = message.data['payload'] as String;
	var endpoint = Uri.parse(message.data['endpoint'] as String);
	var vapidKey = message.data['vapid_key'] as String?;

	var db = await DB.open();
	var subs = await db.listWebPushSubscriptions();
	var sub = subs.firstWhere((sub) {
		// data['endpoint'] is typically missing the hostname
		var subEndpointUri = Uri.parse(sub.endpoint);
		var msgEndpointUri = subEndpointUri.resolveUri(endpoint);
		return subEndpointUri == msgEndpointUri;
	});

	if (sub.vapidKey != vapidKey) {
		throw Exception('VAPID public key mismatch');
	}

	var config = WebPushConfig(
		p256dhPublicKey: sub.p256dhPublicKey,
		p256dhPrivateKey: sub.p256dhPrivateKey,
		authKey: sub.authKey,
	);
	var webPush = await WebPush.import(config);

	List<int> ciphertext = base64.decode(encodedPayload);
	var bytes = await webPush.decrypt(ciphertext);
	var str = utf8.decode(bytes);
	var msg = IrcMessage.parse(str);

	print('Decrypted payload: $msg');

	// TODO: cancel existing notifications on READ
	if (msg.cmd != 'PRIVMSG' && msg.cmd != 'NOTICE') {
		print('Ignoring ${msg.cmd} message');
		return;
class FirebaseStub extends FirebaseProvider {
	@override
	Future<bool> isSupported() async {
		return false;
	}

	var target = msg.params[0];

	var sharedPreferences = await SharedPreferences.getInstance();
	var defaultNickname = sharedPreferences.getString('nickname');
	var defaultRealname = sharedPreferences.getString('realname');

	var networkEntry = await _fetchNetwork(db, sub.network);
	if (networkEntry == null) {
		throw Exception('Got push message for an unknown network #${sub.network}');
	}
	var serverEntry = await _fetchServer(db, networkEntry.server);
	if (serverEntry == null) {
		throw Exception('Network #${sub.network} has an unknown server #${networkEntry.server}');
	@override
  Future<String> createSubscription(String vapidKey) async {
		return '';
	}

	var nickname = serverEntry.nick ?? defaultNickname ?? 'user';
	var realname = defaultRealname ?? nickname;
	var network = NetworkModel(serverEntry, networkEntry, nickname, realname);

	var bufferEntry = await _fetchBuffer(db, target, sub.network);
	if (bufferEntry == null) {
		bufferEntry = BufferEntry(name: target, network: sub.network);
		await db.storeBuffer(bufferEntry);
	}

	var buffer = BufferModel(entry: bufferEntry, network: network);

	var msgEntry = MessageEntry(msg, bufferEntry.id!);

	var notifController = NotificationController();
	await notifController.initialize();

	// TODO: obtain current list of unread messages somehow
	// TODO: use a cached CHANTYPES instead
	var isChannel = target.startsWith('#');
	if (isChannel) {
		notifController.showHighlight([msgEntry], buffer);
	} else {
		notifController.showDirectMessage([msgEntry], buffer);
	}
}

Future<NetworkEntry?> _fetchNetwork(DB db, int id) async {
	var entries = await db.listNetworks();
	for (var entry in entries) {
		if (entry.id == id) {
			return entry;
		}
	}
	return null;
}

Future<ServerEntry?> _fetchServer(DB db, int id) async {
	var entries = await db.listServers();
	for (var entry in entries) {
		if (entry.id == id) {
			return entry;
		}
	}
	return null;
	@override
  void initMessaging() async {}
}

Future<BufferEntry?> _fetchBuffer(DB db, String name, int networkId) async {
	// TODO: use a cached CASEMAPPING instead
	var cm = defaultCaseMapping;

	var entries = await db.listBuffers();
	for (var entry in entries) {
		if (entry.network == networkId && cm(entry.name) == cm(name)) {
			return entry;
		}
	}
	return null;
}
// can be overridden by firebase_impl.dart to enable Firebase
FirebaseProvider firebaseProvider = FirebaseStub();
diff --git a/lib/firebase_impl.dart b/lib/firebase_impl.dart
new file mode 100644
index 0000000..f76d108
--- /dev/null
+++ b/lib/firebase_impl.dart
@@ -0,0 +1,197 @@
import 'dart:convert' show json, utf8, base64;
import 'dart:io';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_android/shared_preferences_android.dart';

import 'database.dart';
import 'firebase.dart';
import 'firebase_options.dart';
import 'irc.dart';
import 'models.dart';
import 'notification_controller.dart';
import 'webpush.dart';

class FirebaseImpl extends FirebaseProvider {
	static final _gatewayEndpoint = Uri.parse(firebaseGatewayUrl);

	@override
	Future<bool> isSupported() async {
		if (!Platform.isAndroid) {
			return false;
		}
		await Firebase.initializeApp(options: firebaseOptions);
		return Platform.isAndroid && FirebaseMessaging.instance.isSupported();
	}

	@override
	Future<String> createSubscription(String vapidKey) async {
		var token = await FirebaseMessaging.instance.getToken();
		var client = HttpClient();
		try {
			var url = _gatewayEndpoint.resolve('/firebase/${firebaseOptions.projectId}/subscribe?token=$token');
			var req = await client.postUrl(url);
			req.headers.contentType = ContentType('application', 'webpush-options+json', charset: 'utf-8');
			req.write(json.encode({
				'vapid': vapidKey,
			}));
			var resp = await req.close();
			if (resp.statusCode ~/ 100 != 2) {
				throw Exception('HTTP error ${resp.statusCode}');
			}

			// TODO: parse subscription resource URL as well

			String? pushLink;
			for (var rawLink in resp.headers['Link'] ?? <String>[]) {
				var link = HeaderValue.parse(rawLink);
				if (link.parameters['rel'] == 'urn:ietf:params:push') {
					pushLink = link.value;
					break;
				}
			}

			if (pushLink == null || !pushLink.startsWith('<') || !pushLink.endsWith('>')) {
				throw FormatException('No valid urn:ietf:params:push Link found');
			}
			var pushUrl = pushLink.substring(1, pushLink.length - 1);
			return _gatewayEndpoint.resolve(pushUrl).toString();
		} finally {
			client.close();
		}
	}

	@override
	void initMessaging() async {
		var supported = await isSupported();
		if (!supported) {
			return;
		}

		FirebaseMessaging.onBackgroundMessage(_handleFirebaseMessage);
		FirebaseMessaging.onMessage.listen(_handleFirebaseMessage);
	}

	// This function may called from a separate Isolate
	Future<void> _handleFirebaseMessage(RemoteMessage message) async {
		print('Received push message: ${message.data}');

		var encodedPayload = message.data['payload'] as String;
		var endpoint = Uri.parse(message.data['endpoint'] as String);
		var vapidKey = message.data['vapid_key'] as String?;

		var db = await DB.open();
		var subs = await db.listWebPushSubscriptions();
		var sub = subs.firstWhere((sub) {
			// data['endpoint'] is typically missing the hostname
			var subEndpointUri = Uri.parse(sub.endpoint);
			var msgEndpointUri = subEndpointUri.resolveUri(endpoint);
			return subEndpointUri == msgEndpointUri;
		});

		if (sub.vapidKey != vapidKey) {
			throw Exception('VAPID public key mismatch');
		}

		var config = WebPushConfig(
			p256dhPublicKey: sub.p256dhPublicKey,
			p256dhPrivateKey: sub.p256dhPrivateKey,
			authKey: sub.authKey,
		);
		var webPush = await WebPush.import(config);

		List<int> ciphertext = base64.decode(encodedPayload);
		var bytes = await webPush.decrypt(ciphertext);
		var str = utf8.decode(bytes);
		var msg = IrcMessage.parse(str);

		print('Decrypted payload: $msg');

		// TODO: cancel existing notifications on READ
		if (msg.cmd != 'PRIVMSG' && msg.cmd != 'NOTICE') {
			print('Ignoring ${msg.cmd} message');
			return;
		}

		var target = msg.params[0];

		if (Platform.isAndroid) {
			// see: https://github.com/flutter/flutter/issues/98473#issuecomment-1060952450
			// see: https://github.com/flutter/flutter/issues/98473#issuecomment-1041895729
			SharedPreferencesAndroid.registerWith();
		}
		var sharedPreferences = await SharedPreferences.getInstance();
		var defaultNickname = sharedPreferences.getString('nickname');
		var defaultRealname = sharedPreferences.getString('realname');

		var networkEntry = await _fetchNetwork(db, sub.network);
		if (networkEntry == null) {
			throw Exception('Got push message for an unknown network #${sub.network}');
		}
		var serverEntry = await _fetchServer(db, networkEntry.server);
		if (serverEntry == null) {
			throw Exception('Network #${sub.network} has an unknown server #${networkEntry.server}');
		}

		var nickname = serverEntry.nick ?? defaultNickname ?? 'user';
		var realname = defaultRealname ?? nickname;
		var network = NetworkModel(serverEntry, networkEntry, nickname, realname);

		var bufferEntry = await _fetchBuffer(db, target, sub.network);
		if (bufferEntry == null) {
			bufferEntry = BufferEntry(name: target, network: sub.network);
			await db.storeBuffer(bufferEntry);
		}

		var buffer = BufferModel(entry: bufferEntry, network: network);

		var msgEntry = MessageEntry(msg, bufferEntry.id!);

		var notifController = NotificationController();
		await notifController.initialize();

		// TODO: obtain current list of unread messages somehow
		// TODO: use a cached CHANTYPES instead
		var isChannel = target.startsWith('#');
		if (isChannel) {
			notifController.showHighlight([msgEntry], buffer);
		} else {
			notifController.showDirectMessage([msgEntry], buffer);
		}
	}

	Future<NetworkEntry?> _fetchNetwork(DB db, int id) async {
		var entries = await db.listNetworks();
		for (var entry in entries) {
			if (entry.id == id) {
				return entry;
			}
		}
		return null;
	}

	Future<ServerEntry?> _fetchServer(DB db, int id) async {
		var entries = await db.listServers();
		for (var entry in entries) {
			if (entry.id == id) {
				return entry;
			}
		}
		return null;
	}

	Future<BufferEntry?> _fetchBuffer(DB db, String name, int networkId) async {
		// TODO: use a cached CASEMAPPING instead
		var cm = defaultCaseMapping;

		var entries = await db.listBuffers();
		for (var entry in entries) {
			if (entry.network == networkId && cm(entry.name) == cm(name)) {
				return entry;
			}
		}
		return null;
	}
}
diff --git a/lib/main.dart b/lib/main.dart
index 498d152..b88f5fe 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -23,9 +23,13 @@ const _debugWorkManager = false;
const _resetWorkManager = false;

void main() {
	run();
}

void run() {
	FlutterError.onError = _handleFlutterError;

	runZonedGuarded(_main, (Object error, StackTrace stack) {
	runZonedGuarded(_run, (Object error, StackTrace stack) {
		FlutterError.reportError(FlutterErrorDetails(
			exception: error,
			stack: stack,
@@ -34,13 +38,13 @@ void main() {
	});
}

void _main() async {
void _run() async {
	var syncReceivePort = ReceivePort('main:sync');
	IsolateNameServer.registerPortWithName(syncReceivePort.sendPort, 'main:sync');

	WidgetsFlutterBinding.ensureInitialized();
	_initWorkManager();
	initFirebaseMessaging();
	firebaseProvider.initMessaging();

	if (Platform.isAndroid) {
		trustIsrgRootX1();
diff --git a/lib/main_firebase.dart b/lib/main_firebase.dart
new file mode 100644
index 0000000..dd587b9
--- /dev/null
+++ b/lib/main_firebase.dart
@@ -0,0 +1,8 @@
import 'firebase.dart';
import 'firebase_impl.dart';
import 'main.dart';

void main() {
	firebaseProvider = FirebaseImpl();
	run();
}
diff --git a/pubspec.lock b/pubspec.lock
index d2c790a..80d8d15 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -355,7 +355,7 @@ packages:
    source: hosted
    version: "2.1.0"
  shared_preferences_linux:
    dependency: transitive
    dependency: "direct main"
    description:
      name: shared_preferences_linux
      url: "https://pub.dartlang.org"
diff --git a/pubspec.yaml b/pubspec.yaml
index ea24f81..8b3a85d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -17,6 +17,7 @@ dependencies:
    sdk: flutter
  provider: ^6.0.1
  shared_preferences: '^2.0.5'
  shared_preferences_linux: '^2.0.5'
  flutter_linkify: '^5.0.0'
  url_launcher: '^6.0.2'
  linkify: '^4.0.0'
diff --git a/tool/gen_firebase_options.dart b/tool/gen_firebase_options.dart
index 5ac7a22..e6b00db 100644
--- a/tool/gen_firebase_options.dart
+++ b/tool/gen_firebase_options.dart
@@ -2,14 +2,19 @@ import 'dart:convert' show json;
import 'dart:io';

void main(List<String> args) async {
	if (args.length != 2) {
		stderr.writeln('usage: gen_firebase_options google-services.json firebase_options.dart');
	if (args.length < 2) {
		stderr.writeln('usage: gen_firebase_options google-services.json firebase_options.dart [gateway_endpoint]');
		return;
	}

	var inputFilename = args[0];
	var outputFilename = args[1];

	var gatewayEndpoint = 'http://10.0.2.2:8082';
	if (args.length >= 3) {
		gatewayEndpoint = args[2];
	}

	var str = await File(inputFilename).readAsString();
	var data = json.decode(str);

@@ -28,6 +33,8 @@ const firebaseOptions = FirebaseOptions(
	messagingSenderId: '$messagingSenderId',
	projectId: '$projectId',
);

const firebaseGatewayUrl = '$gatewayEndpoint';
''';

	await File(outputFilename).writeAsString(gen);

base-commit: fc4308af156f0642bc7ec7bac4910356e20bdd74
prerequisite-patch-id: e21461697314f47d820f59b943ccf3e763edf81c
-- 
2.17.1
I've integrated some of these changes in the branch. Thanks!