~emersion/goguma

802bdadafa670f43b6ace48b0b956eeb112190fb — Simon Ser 3 months ago c737e1b
Add support for soju.im/webpush
M .builds/android.yml => .builds/android.yml +7 -3
@@ 13,8 13,9 @@ secrets:
  - 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
  - 6d21b97d-cd64-4490-b325-acf8b05a542f # keystore.jks
  - 431b0b53-2af2-441b-b879-86c5913bab4d # keystore.properties
  - 4e454305-057f-44c3-9a4e-eeb74d54545b # google-services.json
artifacts:
  - goguma/build/app/outputs/flutter-apk/app-release.apk
  - goguma/build/app/outputs/flutter-apk/app-firebase-release.apk
tasks:
  - setup-tools: |
      sudo chown -R $USER /opt/flutter /opt/android-sdk


@@ 26,9 27,12 @@ tasks:
      cd goguma
      ln -s ~/keystore.properties android/keystore.properties
      ln -s ~/keystore.jks android/keystore.jks
      flutter pub get
      flutter pub run tool/gen_firebase_options.dart ~/google-services.json lib/firebase_options.dart
  - build: |
      cd goguma
      flutter build apk --build-number="$(git rev-list --first-parent --count HEAD)"
      build_number="$(git rev-list --first-parent --count HEAD)"
      flutter build apk --build-number="$build_number" --flavor=firebase --target=lib/main_firebase.dart
  - analyze: |
      cd goguma
      flutter analyze --no-fatal-infos


@@ 37,5 41,5 @@ tasks:
      [ "$(git rev-parse origin/master)" = "$(git rev-parse HEAD)" ] || complete-build
      build_number="$(git rev-list --first-parent --count HEAD)"
      ssh_opts="-o StrictHostKeyChecking=no"
      scp $ssh_opts build/app/outputs/flutter-apk/app-release.apk deploy@emersion.fr:fdroid-goguma-nightly/repo/goguma-"$build_number".apk
      scp $ssh_opts build/app/outputs/flutter-apk/app-firebase-release.apk deploy@emersion.fr:fdroid-goguma-nightly/repo/goguma-"$build_number".apk
      ssh $ssh_opts deploy@emersion.fr "cd fdroid-goguma-nightly && (ls -t repo/*.apk | tail -n +5 | xargs -r rm --) && fdroid update"

M .gitignore => .gitignore +3 -0
@@ 44,3 44,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release

# Firebase API keys
/lib/firebase_options.dart

M android/app/build.gradle => android/app/build.gradle +10 -0
@@ 74,6 74,16 @@ android {
            }
        }
    }

    flavorDimensions "app"

    productFlavors {
        firebase {
            dimension "app"
            versionNameSuffix "-firebase"
            minSdkVersion 21
        }
    }
}

flutter {

M android/app/src/main/AndroidManifest.xml => android/app/src/main/AndroidManifest.xml +5 -0
@@ 41,5 41,10 @@
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />

        <!-- https://firebase.google.com/docs/analytics/configure-data-collection?platform=android -->
        <meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
        <meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
        <meta-data android:name="google_analytics_ssaid_collection_enabled" android:value="false" />
    </application>
</manifest>

A doc/firebase.md => doc/firebase.md +19 -0
@@ 0,0 1,19 @@
# Firebase Cloud Messaging

Firebase Cloud Messaging can be used in combination with [pushgarden] to enable
Web Push support on Android.

First, create a Firebase app and obtain the `google-services.json` file. Then
run:

    flutter pub run tool/gen_firebase_options.dart /path/to/google-services.json lib/firebase_options.dart

Then build Goguma with the `firebase` flavor, with the Firebase main entrypoint
and your pushgarden instance:

    flutter build apk --flavor=firebase --target=lib/main_firebase.dart --dart-define=pushgardenEndpoint='https://example.org'

For instance, to connect from the Android emulator to a locally running
instance of pushgarden, one can use `http://10.0.2.2:8080`.

[pushgarden]: https://git.sr.ht/~emersion/pushgarden

M lib/client.dart => lib/client.dart +18 -0
@@ 73,6 73,7 @@ const _permanentCaps = [
	'soju.im/bouncer-networks',
	'soju.im/no-implicit-names',
	'soju.im/read',
	'soju.im/webpush',
];

var _nextClientId = 0;


@@ 876,6 877,23 @@ class Client {
		});
		_params = _params.apply(realname: realname);
	}

	Future<void> webPushRegister(String endpoint, Map<String, List<int>> keys) {
		Map<String, String> encodedKeys = Map.fromEntries(keys.entries.map((kv) {
			return MapEntry(kv.key, base64Url.encode(kv.value));
		}));
		var msg = IrcMessage('WEBPUSH', ['REGISTER', endpoint, formatIrcTags(encodedKeys)]);
		return _roundtripMessage(msg, (msg) {
			return msg.cmd == 'WEBPUSH' && msg.params[0] == 'REGISTER' && msg.params[1] == endpoint;
		});
	}

	Future<void> webPushUnregister(String endpoint) {
		var msg = IrcMessage('WEBPUSH', ['UNREGISTER', endpoint]);
		return _roundtripMessage(msg, (msg) {
			return msg.cmd == 'WEBPUSH' && msg.params[0] == 'UNREGISTER' && msg.params[1] == endpoint;
		});
	}
}

class ClientMessage extends IrcMessage {

M lib/client_controller.dart => lib/client_controller.dart +55 -0
@@ 2,15 2,18 @@ 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';

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

ConnectParams connectParamsFromServerEntry(ServerEntry entry, { required String defaultNickname, String? defaultRealname }) {
	var nick = entry.nick ?? defaultNickname;


@@ 349,6 352,10 @@ class ClientController {
				client.monitor(l);
			}

			if (client.caps.enabled.contains('soju.im/webpush')) {
				_setupPushSync();
			}

			List<Future<void>> syncFutures = [];

			// Query latest READ status for user targets


@@ 746,4 753,52 @@ class ClientController {
			await _handleChatMessages(target.name, batch.messages);
		}));
	}

	void _setupPushSync() async {
		if (!Platform.isAndroid || !FirebaseMessaging.instance.isSupported()) {
			return;
		}

		print('Enabling push synchronization');

		var subs = await _db.listWebPushSubscriptions();
		var vapidKey = client.isupport.vapid;

		WebPushSubscription? oldSub;
		for (var sub in subs) {
			if (sub.network == network.networkId) {
				oldSub = sub;
				break;
			}
		}

		if (oldSub != null) {
			// TODO: also unregister on Firebase token change

			if (oldSub.vapidKey == vapidKey) {
				// Refresh our subscription
				await client.webPushRegister(oldSub.endpoint, oldSub.getPublicKeys());
				return;
			}

			// TODO: delete our pushgarden subscription
			await client.webPushUnregister(oldSub.endpoint);
			await _db.deleteWebPushSubscription(oldSub.id!);
		}

		var endpoint = await createFirebaseSubscription(vapidKey);
		var webPush = await WebPush.generate();
		var config = await webPush.exportPrivateKeys();
		var newSub = WebPushSubscription(
			network: network.networkId,
			endpoint: endpoint,
			vapidKey: vapidKey,
			p256dhPrivateKey: config.p256dhPrivateKey,
			p256dhPublicKey: config.p256dhPublicKey,
			authKey: config.authKey,
		);

		await client.webPushRegister(endpoint, config.getPublicKeys());
		await _db.storeWebPushSubscription(newSub);
	}
}

M lib/database.dart => lib/database.dart +104 -1
@@ 1,5 1,6 @@
import 'dart:io';
import 'dart:async';
import 'dart:typed_data';

import 'package:flutter/widgets.dart';
import 'package:path/path.dart';


@@ 139,6 140,57 @@ class MessageEntry {
	}
}

class WebPushSubscription {
	int? id;
	final int network;
	final String endpoint;
	final String? vapidKey;
	final Uint8List p256dhPrivateKey;
	final Uint8List p256dhPublicKey;
	final Uint8List authKey;
	final DateTime createdAt;

	Map<String, Object?> toMap() {
		return <String, Object?>{
			'id': id,
			'network': network,
			'endpoint': endpoint,
			'vapid_key': vapidKey,
			'p256dh_private_key': p256dhPrivateKey,
			'p256dh_public_key': p256dhPublicKey,
			'auth_key': authKey,
			'created_at': formatIrcTime(createdAt),
		};
	}

	WebPushSubscription({
		required this.network,
		required this.endpoint,
		required this.p256dhPrivateKey,
		required this.p256dhPublicKey,
		required this.authKey,
		this.vapidKey,
	}) :
		createdAt = DateTime.now();

	WebPushSubscription.fromMap(Map<String, dynamic> m) :
		id = m['id'] as int,
		network = m['network'] as int,
		endpoint = m['endpoint'] as String,
		vapidKey = m['vapid_key'] as String?,
		p256dhPrivateKey = m['p256dh_private_key'] as Uint8List,
		p256dhPublicKey = m['p256dh_public_key'] as Uint8List,
		authKey = m['auth_key'] as Uint8List,
		createdAt = DateTime.parse(m['created_at'] as String);

	Map<String, Uint8List> getPublicKeys() {
		return {
			'p256dh': p256dhPublicKey,
			'auth': authKey,
		};
	}
}

class DB {
	final Database _db;



@@ 210,6 262,20 @@ class DB {
					CREATE INDEX index_message_buffer_time
					ON Message(buffer, time);
				''');
				batch.execute('''
					CREATE TABLE WebPushSubscription (
						id INTEGER PRIMARY KEY,
						network INTEGER NOT NULL,
						endpoint TEXT NOT NULL,
						vapid_key TEXT,
						p256dh_public_key BLOB,
						p256dh_private_key BLOB,
						auth_key BLOB,
						created_at TEXT NOT NULL,
						FOREIGN KEY (network) REFERENCES Network(id) ON DELETE CASCADE,
						UNIQUE(network, endpoint)
					);
				''');
				await batch.commit();
			},
			onUpgrade: (db, prevVersion, newVersion) async {


@@ 242,12 308,28 @@ class DB {
						ALTER TABLE Buffer ADD COLUMN realname TEXT;
					''');
				}
				if (prevVersion < 7) {
					batch.execute('''
						CREATE TABLE WebPushSubscription (
							id INTEGER PRIMARY KEY,
							network INTEGER NOT NULL,
							endpoint TEXT NOT NULL,
							vapid_key TEXT,
							p256dh_public_key BLOB,
							p256dh_private_key BLOB,
							auth_key BLOB,
							created_at TEXT NOT NULL,
							FOREIGN KEY (network) REFERENCES Network(id) ON DELETE CASCADE,
							UNIQUE(network, endpoint)
						);
					''');
				}
				await batch.commit();
			},
			onDowngrade: (_, prevVersion, newVersion) async {
				throw Exception('Attempted to downgrade database from version $prevVersion to version $newVersion');
			},
			version: 6,
			version: 7,
		);
		return DB._(db);
	}


@@ 412,4 494,25 @@ class DB {
			}));
		});
	}

	Future<List<WebPushSubscription>> listWebPushSubscriptions() async {
		var entries = await _db.rawQuery('''
			SELECT id, network, endpoint, vapid_key, p256dh_public_key,
				p256dh_private_key, auth_key, created_at
			FROM WebPushSubscription
		''');
		return entries.map((m) => WebPushSubscription.fromMap(m)).toList();
	}

	Future<void> storeWebPushSubscription(WebPushSubscription entry) async {
		if (entry.id == null) {
			entry.id = await _db.insert('WebPushSubscription', entry.toMap());
		} else {
			await _updateById('WebPushSubscription', entry.toMap());
		}
	}

	Future<void> deleteWebPushSubscription(int id) async {
		await _db.rawDelete('DELETE FROM WebPushSubscription WHERE id = ?', [id]);
	}
}

A lib/firebase.dart => lib/firebase.dart +187 -0
@@ 0,0 1,187 @@
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_android/shared_preferences_android.dart';

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

FirebaseOptions? firebaseOptions;

final _gatewayEndpoint = Uri.parse(
	String.fromEnvironment('pushgardenEndpoint', defaultValue: 'https://pushgarden.emersion.fr')
);

Future<String> createFirebaseSubscription(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();
	}
}

void initFirebaseMessaging() async {
	if (!Platform.isAndroid || firebaseOptions == null) {
		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 push message 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];

	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}');
	}

	// See: https://github.com/flutter/flutter/issues/98473#issuecomment-1060952450
	if (Platform.isAndroid) {
		SharedPreferencesAndroid.registerWith();
	}
	var prefs = await Prefs.load();

	var nickname = serverEntry.nick ?? prefs.nickname;
	var realname = prefs.realname ?? 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: 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;
}

M lib/irc.dart => lib/irc.dart +9 -0
@@ 423,6 423,7 @@ class IrcIsupportRegistry {
	int? _topicLen, _nickLen, _realnameLen;
	List<String>? _chanModes;
	IrcIsupportElist? _elist;
	String? _vapid;

	String? get network => _network;
	String get chanTypes => _chanTypes ?? '';


@@ 437,6 438,7 @@ class IrcIsupportRegistry {
	int? get realnameLen => _realnameLen;
	List<String> get chanModes => UnmodifiableListView(_chanModes ?? ['beI', 'k', 'l', 'imnst']);
	IrcIsupportElist? get elist => _elist;
	String? get vapid => _vapid;

	void parse(List<String> tokens) {
		for (var tok in tokens) {


@@ 479,6 481,9 @@ class IrcIsupportRegistry {
				case 'TOPIC':
					_topicLen = null;
					break;
				case 'VAPID':
					_vapid = null;
					break;
				case 'WHOX':
					_whox = false;
					break;


@@ 560,6 565,9 @@ class IrcIsupportRegistry {
				}
				_topicLen = int.parse(v);
				break;
			case 'VAPID':
				_vapid = v;
				break;
			case 'WHOX':
				_whox = true;
				break;


@@ 580,6 588,7 @@ class IrcIsupportRegistry {
		_nickLen = null;
		_realnameLen = null;
		_elist = null;
		_vapid = null;
	}
}


M lib/main.dart => lib/main.dart +2 -0
@@ 13,6 13,7 @@ import 'app.dart';
import 'client.dart';
import 'client_controller.dart';
import 'database.dart';
import 'firebase.dart';
import 'models.dart';
import 'notification_controller.dart';
import 'prefs.dart';


@@ 39,6 40,7 @@ void _main() async {

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

	if (Platform.isAndroid) {
		trustIsrgRootX1();

A lib/main_firebase.dart => lib/main_firebase.dart +9 -0
@@ 0,0 1,9 @@
// firebase_options.dart is generated -- see doc/firebase.md
import 'firebase_options.dart' as provider;
import 'firebase.dart';
import 'main.dart' as base;

void main() {
	firebaseOptions = provider.firebaseOptions;
	base.main();
}

A lib/webpush.dart => lib/webpush.dart +115 -0
@@ 0,0 1,115 @@
import 'dart:typed_data';

import 'package:webcrypto/webcrypto.dart';

const _saltSize = 16;
const _ikmSize = 32;
const _contentEncryptionKeySize = 16;
const _nonceSize = 12;

const _paddingDelimiter = 0x02;

class WebPush {
	final EcdhPrivateKey _p256dhPrivateKey;
	final EcdhPublicKey _p256dhPublicKey;
	final Uint8List _authKey;

	WebPush._(this._p256dhPrivateKey, this._p256dhPublicKey, this._authKey);

	static Future<WebPush> generate() async {
		var authKey = Uint8List(16);
		fillRandomBytes(authKey);

		var p256dhKeyPair = await EcdhPrivateKey.generateKey(EllipticCurve.p256);
		return WebPush._(p256dhKeyPair.privateKey, p256dhKeyPair.publicKey, authKey);
	}

	static Future<WebPush> import(WebPushConfig raw) async {
		var p256dhPrivateKey = await EcdhPrivateKey.importPkcs8Key(raw.p256dhPrivateKey, EllipticCurve.p256);
		var p256dhPublicKey = await EcdhPublicKey.importRawKey(raw.p256dhPublicKey, EllipticCurve.p256);
		return WebPush._(p256dhPrivateKey, p256dhPublicKey, raw.authKey);
	}

	Future<Map<String, Uint8List>> exportPublicKeys() async {
		return {
			'p256dh': await _p256dhPublicKey.exportRawKey(),
			'auth': _authKey,
		};
	}

	Future<WebPushConfig> exportPrivateKeys() async {
		return WebPushConfig(
			p256dhPrivateKey: await _p256dhPrivateKey.exportPkcs8Key(),
			p256dhPublicKey: await _p256dhPublicKey.exportRawKey(),
			authKey: _authKey,
		);
	}

	Future<Uint8List> decrypt(List<int> buf) async {
		var offset = 0;

		var salt = buf.sublist(offset, offset + _saltSize);
		offset += salt.length;

		var recordSizeBytes = buf.sublist(offset, offset + 4);
		offset += recordSizeBytes.length;

		var serverPubKeySize = buf[offset];
		offset++;

		var serverPubKeyBytes = buf.sublist(offset, offset + serverPubKeySize);
		offset += serverPubKeyBytes.length;

		var body = buf.sublist(offset);

		var recordSize = ByteData.sublistView(Uint8List.fromList(recordSizeBytes)).getUint32(0, Endian.big);
		if (recordSize != buf.length) {
			throw FormatException('Encrypted payload size (${buf.length}) doesn\'t match record size field ($recordSize)');
		}

		var serverOneTimePubKey = await EcdhPublicKey.importRawKey(serverPubKeyBytes, EllipticCurve.p256);
		var sharedEcdhSecret = await _p256dhPrivateKey.deriveBits(32 * 8, serverOneTimePubKey);
		var clientPubKeyRaw = await _p256dhPublicKey.exportRawKey();

		var info = [
			...'WebPush: info'.codeUnits,
			0,
			...clientPubKeyRaw,
			...serverPubKeyBytes,
		];
		var hkdfSecretKey = await HkdfSecretKey.importRawKey(sharedEcdhSecret);
		var ikm = await hkdfSecretKey.deriveBits(_ikmSize * 8, Hash.sha256, _authKey, info);

		info = [...'Content-Encoding: aes128gcm'.codeUnits, 0];
		hkdfSecretKey = await HkdfSecretKey.importRawKey(ikm);
		var contentEncryptionKeyBytes = await hkdfSecretKey.deriveBits(_contentEncryptionKeySize * 8, Hash.sha256, salt, info);
		info = [...'Content-Encoding: nonce'.codeUnits, 0];
		var nonce = await hkdfSecretKey.deriveBits(_nonceSize * 8, Hash.sha256, salt, info);

		var aesSecretKey = await AesGcmSecretKey.importRawKey(contentEncryptionKeyBytes);
		var cleartext = await aesSecretKey.decryptBytes(body, nonce, tagLength: _contentEncryptionKeySize * 8);

		var paddingIndex = cleartext.lastIndexOf(_paddingDelimiter);
		if (paddingIndex < 0) {
			throw FormatException('Missing padding delimiter in cleartext');
		}
		cleartext = cleartext.sublist(0, paddingIndex);

		return cleartext;
	}
}

class WebPushConfig {
	final Uint8List p256dhPublicKey;
	final Uint8List p256dhPrivateKey;
	final Uint8List authKey;

	WebPushConfig({ required this.p256dhPublicKey, required this.p256dhPrivateKey, required this.authKey });

	Map<String, Uint8List> getPublicKeys() {
		return {
			'p256dh': p256dhPublicKey,
			'auth': authKey,
		};
	}
}

M pubspec.lock => pubspec.lock +52 -1
@@ 92,6 92,48 @@ packages:
      url: "https://pub.dartlang.org"
    source: hosted
    version: "6.1.2"
  firebase_core:
    dependency: "direct main"
    description:
      name: firebase_core
      url: "https://pub.dartlang.org"
    source: hosted
    version: "1.13.1"
  firebase_core_platform_interface:
    dependency: transitive
    description:
      name: firebase_core_platform_interface
      url: "https://pub.dartlang.org"
    source: hosted
    version: "4.2.5"
  firebase_core_web:
    dependency: transitive
    description:
      name: firebase_core_web
      url: "https://pub.dartlang.org"
    source: hosted
    version: "1.6.1"
  firebase_messaging:
    dependency: "direct main"
    description:
      name: firebase_messaging
      url: "https://pub.dartlang.org"
    source: hosted
    version: "11.2.8"
  firebase_messaging_platform_interface:
    dependency: transitive
    description:
      name: firebase_messaging_platform_interface
      url: "https://pub.dartlang.org"
    source: hosted
    version: "3.2.1"
  firebase_messaging_web:
    dependency: transitive
    description:
      name: firebase_messaging_web
      url: "https://pub.dartlang.org"
    source: hosted
    version: "2.2.9"
  flutter:
    dependency: "direct main"
    description: flutter


@@ 299,7 341,7 @@ packages:
    source: hosted
    version: "2.0.13"
  shared_preferences_android:
    dependency: transitive
    dependency: "direct main"
    description:
      name: shared_preferences_android
      url: "https://pub.dartlang.org"


@@ 464,6 506,15 @@ packages:
      url: "https://pub.dartlang.org"
    source: hosted
    version: "2.1.1"
  webcrypto:
    dependency: "direct main"
    description:
      path: "."
      ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
      resolved-ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
      url: "https://github.com/google/webcrypto.dart.git"
    source: git
    version: "0.5.2"
  win32:
    dependency: transitive
    description:

M pubspec.yaml => pubspec.yaml +9 -0
@@ 28,6 28,15 @@ dependencies:
  flutter_background: ^1.1.0
  connectivity_plus: ^2.2.1
  flutter_typeahead: ^3.2.4
  # TODO: replace "webcrypto" with the "cryptography" package once they have a
  # pure Dart Ecdh.p256 implementation
  webcrypto:
    git:
      url: https://github.com/google/webcrypto.dart.git
      ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
  firebase_core: ^1.13.1
  firebase_messaging: ^11.2.8
  shared_preferences_android: ^2.0.11

dev_dependencies:
  flutter_lints: ^1.0.4

A tool/gen_firebase_options.dart => tool/gen_firebase_options.dart +32 -0
@@ 0,0 1,32 @@
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');
		return;
	}

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

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

	var projectId = data['project_info']['project_id'] as String;
	var messagingSenderId = data['project_info']['project_number'] as String;
	var appId = data['client'][0]['client_info']['mobilesdk_app_id'] as String;
	var apiKey = data['client'][0]['api_key'][0]['current_key'] as String;

	var gen = '''import 'package:firebase_core/firebase_core.dart';

const firebaseOptions = FirebaseOptions(
	apiKey: '$apiKey',
	appId: '$appId',
	messagingSenderId: '$messagingSenderId',
	projectId: '$projectId',
);
''';

	await File(outputFilename).writeAsString(gen);
}