1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:convert';
6import 'dart:typed_data';
7
8import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
9
10import 'message_codec.dart';
11
12export 'dart:typed_data' show ByteData;
13
14export 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
15
16export 'message_codec.dart' show MethodCall;
17
18const int _writeBufferStartCapacity = 64;
19
20/// [MessageCodec] with unencoded binary messages represented using [ByteData].
21///
22/// On Android, messages will be represented using `java.nio.ByteBuffer`.
23/// On iOS, messages will be represented using `NSData`.
24///
25/// When sending outgoing messages from Android, be sure to use direct `ByteBuffer`
26/// as opposed to indirect. The `wrap()` API provides indirect buffers by default
27/// and you will get empty `ByteData` objects in Dart.
28class BinaryCodec implements MessageCodec<ByteData> {
29 /// Creates a [MessageCodec] with unencoded binary messages represented using
30 /// [ByteData].
31 const BinaryCodec();
32
33 @override
34 ByteData? decodeMessage(ByteData? message) => message;
35
36 @override
37 ByteData? encodeMessage(ByteData? message) => message;
38}
39
40/// [MessageCodec] with UTF-8 encoded String messages.
41///
42/// On Android, messages will be represented using `java.util.String`.
43/// On iOS, messages will be represented using `NSString`.
44class StringCodec implements MessageCodec<String> {
45 /// Creates a [MessageCodec] with UTF-8 encoded String messages.
46 const StringCodec();
47
48 @override
49 String? decodeMessage(ByteData? message) {
50 if (message == null) {
51 return null;
52 }
53 return utf8.decode(Uint8List.sublistView(message));
54 }
55
56 @override
57 ByteData? encodeMessage(String? message) {
58 if (message == null) {
59 return null;
60 }
61 return ByteData.sublistView(utf8.encode(message));
62 }
63}
64
65/// [MessageCodec] with UTF-8 encoded JSON messages.
66///
67/// Supported messages are acyclic values of these forms:
68///
69/// * null
70/// * [bool]s
71/// * [num]s
72/// * [String]s
73/// * [List]s of supported values
74/// * [Map]s from strings to supported values
75///
76/// On Android, messages are decoded using the `org.json` library.
77/// On iOS, messages are decoded using the `NSJSONSerialization` library.
78/// In both cases, the use of top-level simple messages (null, [bool], [num],
79/// and [String]) is supported (by the Flutter SDK). The decoded value will be
80/// null/nil for null, and identical to what would result from decoding a
81/// singleton JSON array with a Boolean, number, or string value, and then
82/// extracting its single element.
83///
84/// The type returned from [decodeMessage] is `dynamic` (not `Object?`), which
85/// means *no type checking is performed on its return value*. It is strongly
86/// recommended that the return value be immediately cast to a known type to
87/// prevent runtime errors due to typos that the type checker could otherwise
88/// catch.
89class JSONMessageCodec implements MessageCodec<Object?> {
90 // The codec serializes messages as defined by the JSON codec of the
91 // dart:convert package. The format used must match the Android and
92 // iOS counterparts.
93
94 /// Creates a [MessageCodec] with UTF-8 encoded JSON messages.
95 const JSONMessageCodec();
96
97 @override
98 ByteData? encodeMessage(Object? message) {
99 if (message == null) {
100 return null;
101 }
102 return const StringCodec().encodeMessage(json.encode(message));
103 }
104
105 @override
106 dynamic decodeMessage(ByteData? message) {
107 if (message == null) {
108 return message;
109 }
110 return json.decode(const StringCodec().decodeMessage(message)!);
111 }
112}
113
114/// [MethodCodec] with UTF-8 encoded JSON method calls and result envelopes.
115///
116/// Values supported as method arguments and result payloads are those supported
117/// by [JSONMessageCodec].
118class JSONMethodCodec implements MethodCodec {
119 // The codec serializes method calls, and result envelopes as outlined below.
120 // This format must match the Android and iOS counterparts.
121 //
122 // * Individual values are serialized as defined by the JSON codec of the
123 // dart:convert package.
124 // * Method calls are serialized as two-element maps, with the method name
125 // keyed by 'method' and the arguments keyed by 'args'.
126 // * Reply envelopes are serialized as either:
127 // * one-element lists containing the successful result as its single
128 // element, or
129 // * three-element lists containing, in order, an error code String, an
130 // error message String, and an error details value.
131
132 /// Creates a [MethodCodec] with UTF-8 encoded JSON method calls and result
133 /// envelopes.
134 const JSONMethodCodec();
135
136 @override
137 ByteData encodeMethodCall(MethodCall methodCall) {
138 return const JSONMessageCodec().encodeMessage(<String, Object?>{
139 'method': methodCall.method,
140 'args': methodCall.arguments,
141 })!;
142 }
143
144 @override
145 MethodCall decodeMethodCall(ByteData? methodCall) {
146 final Object? decoded = const JSONMessageCodec().decodeMessage(methodCall);
147 if (decoded is! Map) {
148 throw FormatException('Expected method call Map, got $decoded');
149 }
150 if (decoded case {'method': final String method}) {
151 return MethodCall(method, decoded['args']);
152 }
153 throw FormatException('Invalid method call: $decoded');
154 }
155
156 @override
157 dynamic decodeEnvelope(ByteData envelope) {
158 final Object? decoded = const JSONMessageCodec().decodeMessage(envelope);
159 if (decoded is! List) {
160 throw FormatException('Expected envelope List, got $decoded');
161 }
162 if (decoded.length == 1) {
163 return decoded[0];
164 }
165 if (decoded.length == 3 &&
166 decoded[0] is String &&
167 (decoded[1] == null || decoded[1] is String)) {
168 throw PlatformException(
169 code: decoded[0] as String,
170 message: decoded[1] as String?,
171 details: decoded[2],
172 );
173 }
174 if (decoded.length == 4 &&
175 decoded[0] is String &&
176 (decoded[1] == null || decoded[1] is String) &&
177 (decoded[3] == null || decoded[3] is String)) {
178 throw PlatformException(
179 code: decoded[0] as String,
180 message: decoded[1] as String?,
181 details: decoded[2],
182 stacktrace: decoded[3] as String?,
183 );
184 }
185 throw FormatException('Invalid envelope: $decoded');
186 }
187
188 @override
189 ByteData encodeSuccessEnvelope(Object? result) {
190 return const JSONMessageCodec().encodeMessage(<Object?>[result])!;
191 }
192
193 @override
194 ByteData encodeErrorEnvelope({required String code, String? message, Object? details}) {
195 return const JSONMessageCodec().encodeMessage(<Object?>[code, message, details])!;
196 }
197}
198
199/// [MessageCodec] using the Flutter standard binary encoding.
200///
201/// Supported messages are acyclic values of these forms:
202///
203/// * null
204/// * [bool]s
205/// * [num]s
206/// * [String]s
207/// * [Uint8List]s, [Int32List]s, [Int64List]s, [Float64List]s
208/// * [List]s of supported values
209/// * [Map]s from supported values to supported values
210///
211/// Decoded values will use `List<Object?>` and `Map<Object?, Object?>`
212/// irrespective of content.
213///
214/// The type returned from [decodeMessage] is `dynamic` (not `Object?`), which
215/// means *no type checking is performed on its return value*. It is strongly
216/// recommended that the return value be immediately cast to a known type to
217/// prevent runtime errors due to typos that the type checker could otherwise
218/// catch.
219///
220/// The codec is extensible by subclasses overriding [writeValue] and
221/// [readValueOfType].
222///
223/// ## Android specifics
224///
225/// On Android, messages are represented as follows:
226///
227/// * null: null
228/// * [bool]\: `java.lang.Boolean`
229/// * [int]\: `java.lang.Integer` for values that are representable using 32-bit
230/// two's complement; `java.lang.Long` otherwise
231/// * [double]\: `java.lang.Double`
232/// * [String]\: `java.lang.String`
233/// * [Uint8List]\: `byte[]`
234/// * [Int32List]\: `int[]`
235/// * [Int64List]\: `long[]`
236/// * [Float64List]\: `double[]`
237/// * [List]\: `java.util.ArrayList`
238/// * [Map]\: `java.util.HashMap`
239///
240/// When sending a `java.math.BigInteger` from Java, it is converted into a
241/// [String] with the hexadecimal representation of the integer. (The value is
242/// tagged as being a big integer; subclasses of this class could be made to
243/// support it natively; see the discussion at [writeValue].) This codec does
244/// not support sending big integers from Dart.
245///
246/// ## iOS specifics
247///
248/// On iOS, messages are represented as follows:
249///
250/// * null: nil
251/// * [bool]\: `NSNumber numberWithBool:`
252/// * [int]\: `NSNumber numberWithInt:` for values that are representable using
253/// 32-bit two's complement; `NSNumber numberWithLong:` otherwise
254/// * [double]\: `NSNumber numberWithDouble:`
255/// * [String]\: `NSString`
256/// * [Uint8List], [Int32List], [Int64List], [Float64List]\:
257/// `FlutterStandardTypedData`
258/// * [List]\: `NSArray`
259/// * [Map]\: `NSDictionary`
260class StandardMessageCodec implements MessageCodec<Object?> {
261 /// Creates a [MessageCodec] using the Flutter standard binary encoding.
262 const StandardMessageCodec();
263
264 // The codec serializes messages as outlined below. This format must match the
265 // Android and iOS counterparts and cannot change (as it's possible for
266 // someone to end up using this for persistent storage).
267 //
268 // * A single byte with one of the constant values below determines the
269 // type of the value.
270 // * The serialization of the value itself follows the type byte.
271 // * Numbers are represented using the host endianness throughout.
272 // * Lengths and sizes of serialized parts are encoded using an expanding
273 // format optimized for the common case of small non-negative integers:
274 // * values 0..253 inclusive using one byte with that value;
275 // * values 254..2^16 inclusive using three bytes, the first of which is
276 // 254, the next two the usual unsigned representation of the value;
277 // * values 2^16+1..2^32 inclusive using five bytes, the first of which is
278 // 255, the next four the usual unsigned representation of the value.
279 // * null, true, and false have empty serialization; they are encoded directly
280 // in the type byte (using _valueNull, _valueTrue, _valueFalse)
281 // * Integers representable in 32 bits are encoded using 4 bytes two's
282 // complement representation.
283 // * Larger integers are encoded using 8 bytes two's complement
284 // representation.
285 // * doubles are encoded using the IEEE 754 64-bit double-precision binary
286 // format. Zero bytes are added before the encoded double value to align it
287 // to a 64 bit boundary in the full message.
288 // * Strings are encoded using their UTF-8 representation. First the length
289 // of that in bytes is encoded using the expanding format, then follows the
290 // UTF-8 encoding itself.
291 // * Uint8Lists, Int32Lists, Int64Lists, Float32Lists, and Float64Lists are
292 // encoded by first encoding the list's element count in the expanding
293 // format, then the smallest number of zero bytes needed to align the
294 // position in the full message with a multiple of the number of bytes per
295 // element, then the encoding of the list elements themselves, end-to-end
296 // with no additional type information, using two's complement or IEEE 754
297 // as applicable.
298 // * Lists are encoded by first encoding their length in the expanding format,
299 // then follows the recursive encoding of each element value, including the
300 // type byte (Lists are assumed to be heterogeneous).
301 // * Maps are encoded by first encoding their length in the expanding format,
302 // then follows the recursive encoding of each key/value pair, including the
303 // type byte for both (Maps are assumed to be heterogeneous).
304 //
305 // The type labels below must not change, since it's possible for this interface
306 // to be used for persistent storage.
307 static const int _valueNull = 0;
308 static const int _valueTrue = 1;
309 static const int _valueFalse = 2;
310 static const int _valueInt32 = 3;
311 static const int _valueInt64 = 4;
312 static const int _valueLargeInt = 5;
313 static const int _valueFloat64 = 6;
314 static const int _valueString = 7;
315 static const int _valueUint8List = 8;
316 static const int _valueInt32List = 9;
317 static const int _valueInt64List = 10;
318 static const int _valueFloat64List = 11;
319 static const int _valueList = 12;
320 static const int _valueMap = 13;
321 static const int _valueFloat32List = 14;
322
323 @override
324 ByteData? encodeMessage(Object? message) {
325 if (message == null) {
326 return null;
327 }
328 final WriteBuffer buffer = WriteBuffer(startCapacity: _writeBufferStartCapacity);
329 writeValue(buffer, message);
330 return buffer.done();
331 }
332
333 @override
334 dynamic decodeMessage(ByteData? message) {
335 if (message == null) {
336 return null;
337 }
338 final ReadBuffer buffer = ReadBuffer(message);
339 final Object? result = readValue(buffer);
340 if (buffer.hasRemaining) {
341 throw const FormatException('Message corrupted');
342 }
343 return result;
344 }
345
346 /// Writes [value] to [buffer] by first writing a type discriminator
347 /// byte, then the value itself.
348 ///
349 /// This method may be called recursively to serialize container values.
350 ///
351 /// Type discriminators 0 through 127 inclusive are reserved for use by the
352 /// base class, as follows:
353 ///
354 /// * null = 0
355 /// * true = 1
356 /// * false = 2
357 /// * 32 bit integer = 3
358 /// * 64 bit integer = 4
359 /// * larger integers = 5 (see below)
360 /// * 64 bit floating-point number = 6
361 /// * String = 7
362 /// * Uint8List = 8
363 /// * Int32List = 9
364 /// * Int64List = 10
365 /// * Float64List = 11
366 /// * List = 12
367 /// * Map = 13
368 /// * Float32List = 14
369 /// * Reserved for future expansion: 15..127
370 ///
371 /// The codec can be extended by overriding this method, calling super
372 /// for values that the extension does not handle. Type discriminators
373 /// used by extensions must be greater than or equal to 128 in order to avoid
374 /// clashes with any later extensions to the base class.
375 ///
376 /// The "larger integers" type, 5, is never used by [writeValue]. A subclass
377 /// could represent big integers from another package using that type. The
378 /// format is first the type byte (0x05), then the actual number as an ASCII
379 /// string giving the hexadecimal representation of the integer, with the
380 /// string's length as encoded by [writeSize] followed by the string bytes. On
381 /// Android, that would get converted to a `java.math.BigInteger` object. On
382 /// iOS, the string representation is returned.
383 void writeValue(WriteBuffer buffer, Object? value) {
384 if (value == null) {
385 buffer.putUint8(_valueNull);
386 } else if (value is bool) {
387 buffer.putUint8(value ? _valueTrue : _valueFalse);
388 } else if (value is double) {
389 // Double precedes int because in JS everything is a double.
390 // Therefore in JS, both `is int` and `is double` always
391 // return `true`. If we check int first, we'll end up treating
392 // all numbers as ints and attempt the int32/int64 conversion,
393 // which is wrong. This precedence rule is irrelevant when
394 // decoding because we use tags to detect the type of value.
395 buffer.putUint8(_valueFloat64);
396 buffer.putFloat64(value);
397 // ignore: avoid_double_and_int_checks, JS code always goes through the `double` path above
398 } else if (value is int) {
399 if (-0x7fffffff - 1 <= value && value <= 0x7fffffff) {
400 buffer.putUint8(_valueInt32);
401 buffer.putInt32(value);
402 } else {
403 buffer.putUint8(_valueInt64);
404 buffer.putInt64(value);
405 }
406 } else if (value is String) {
407 buffer.putUint8(_valueString);
408 final Uint8List asciiBytes = Uint8List(value.length);
409 Uint8List? utf8Bytes;
410 int utf8Offset = 0;
411 // Only do utf8 encoding if we encounter non-ascii characters.
412 for (int i = 0; i < value.length; i += 1) {
413 final int char = value.codeUnitAt(i);
414 if (char <= 0x7f) {
415 asciiBytes[i] = char;
416 } else {
417 utf8Bytes = utf8.encode(value.substring(i));
418 utf8Offset = i;
419 break;
420 }
421 }
422 if (utf8Bytes != null) {
423 writeSize(buffer, utf8Offset + utf8Bytes.length);
424 buffer.putUint8List(Uint8List.sublistView(asciiBytes, 0, utf8Offset));
425 buffer.putUint8List(utf8Bytes);
426 } else {
427 writeSize(buffer, asciiBytes.length);
428 buffer.putUint8List(asciiBytes);
429 }
430 } else if (value is Uint8List) {
431 buffer.putUint8(_valueUint8List);
432 writeSize(buffer, value.length);
433 buffer.putUint8List(value);
434 } else if (value is Int32List) {
435 buffer.putUint8(_valueInt32List);
436 writeSize(buffer, value.length);
437 buffer.putInt32List(value);
438 } else if (value is Int64List) {
439 buffer.putUint8(_valueInt64List);
440 writeSize(buffer, value.length);
441 buffer.putInt64List(value);
442 } else if (value is Float32List) {
443 buffer.putUint8(_valueFloat32List);
444 writeSize(buffer, value.length);
445 buffer.putFloat32List(value);
446 } else if (value is Float64List) {
447 buffer.putUint8(_valueFloat64List);
448 writeSize(buffer, value.length);
449 buffer.putFloat64List(value);
450 } else if (value is List) {
451 buffer.putUint8(_valueList);
452 writeSize(buffer, value.length);
453 for (final Object? item in value) {
454 writeValue(buffer, item);
455 }
456 } else if (value is Map) {
457 buffer.putUint8(_valueMap);
458 writeSize(buffer, value.length);
459 value.forEach((Object? key, Object? value) {
460 writeValue(buffer, key);
461 writeValue(buffer, value);
462 });
463 } else {
464 throw ArgumentError.value(value);
465 }
466 }
467
468 /// Reads a value from [buffer] as written by [writeValue].
469 ///
470 /// This method is intended for use by subclasses overriding
471 /// [readValueOfType].
472 Object? readValue(ReadBuffer buffer) {
473 if (!buffer.hasRemaining) {
474 throw const FormatException('Message corrupted');
475 }
476 final int type = buffer.getUint8();
477 return readValueOfType(type, buffer);
478 }
479
480 /// Reads a value of the indicated [type] from [buffer].
481 ///
482 /// The codec can be extended by overriding this method, calling super for
483 /// types that the extension does not handle. See the discussion at
484 /// [writeValue].
485 Object? readValueOfType(int type, ReadBuffer buffer) {
486 switch (type) {
487 case _valueNull:
488 return null;
489 case _valueTrue:
490 return true;
491 case _valueFalse:
492 return false;
493 case _valueInt32:
494 return buffer.getInt32();
495 case _valueInt64:
496 return buffer.getInt64();
497 case _valueFloat64:
498 return buffer.getFloat64();
499 case _valueLargeInt:
500 case _valueString:
501 final int length = readSize(buffer);
502 return utf8.decoder.convert(buffer.getUint8List(length));
503 case _valueUint8List:
504 final int length = readSize(buffer);
505 return buffer.getUint8List(length);
506 case _valueInt32List:
507 final int length = readSize(buffer);
508 return buffer.getInt32List(length);
509 case _valueInt64List:
510 final int length = readSize(buffer);
511 return buffer.getInt64List(length);
512 case _valueFloat32List:
513 final int length = readSize(buffer);
514 return buffer.getFloat32List(length);
515 case _valueFloat64List:
516 final int length = readSize(buffer);
517 return buffer.getFloat64List(length);
518 case _valueList:
519 final int length = readSize(buffer);
520 final List<Object?> result = List<Object?>.filled(length, null);
521 for (int i = 0; i < length; i++) {
522 result[i] = readValue(buffer);
523 }
524 return result;
525 case _valueMap:
526 final int length = readSize(buffer);
527 final Map<Object?, Object?> result = <Object?, Object?>{};
528 for (int i = 0; i < length; i++) {
529 result[readValue(buffer)] = readValue(buffer);
530 }
531 return result;
532 default:
533 throw const FormatException('Message corrupted');
534 }
535 }
536
537 /// Writes a non-negative 32-bit integer [value] to [buffer]
538 /// using an expanding 1-5 byte encoding that optimizes for small values.
539 ///
540 /// This method is intended for use by subclasses overriding
541 /// [writeValue].
542 void writeSize(WriteBuffer buffer, int value) {
543 assert(0 <= value && value <= 0xffffffff);
544 if (value < 254) {
545 buffer.putUint8(value);
546 } else if (value <= 0xffff) {
547 buffer.putUint8(254);
548 buffer.putUint16(value);
549 } else {
550 buffer.putUint8(255);
551 buffer.putUint32(value);
552 }
553 }
554
555 /// Reads a non-negative int from [buffer] as written by [writeSize].
556 ///
557 /// This method is intended for use by subclasses overriding
558 /// [readValueOfType].
559 int readSize(ReadBuffer buffer) {
560 final int value = buffer.getUint8();
561 return switch (value) {
562 254 => buffer.getUint16(),
563 255 => buffer.getUint32(),
564 _ => value,
565 };
566 }
567}
568
569/// [MethodCodec] using the Flutter standard binary encoding.
570///
571/// The standard codec is guaranteed to be compatible with the corresponding
572/// standard codec for FlutterMethodChannels on the host platform. These parts
573/// of the Flutter SDK are evolved synchronously.
574///
575/// Values supported as method arguments and result payloads are those supported
576/// by [StandardMessageCodec].
577class StandardMethodCodec implements MethodCodec {
578 // The codec method calls, and result envelopes as outlined below. This format
579 // must match the Android and iOS counterparts.
580 //
581 // * Individual values are encoded using [StandardMessageCodec].
582 // * Method calls are encoded using the concatenation of the encoding
583 // of the method name String and the arguments value.
584 // * Reply envelopes are encoded using first a single byte to distinguish the
585 // success case (0) from the error case (1). Then follows:
586 // * In the success case, the encoding of the result value.
587 // * In the error case, the concatenation of the encoding of the error code
588 // string, the error message string, and the error details value.
589
590 /// Creates a [MethodCodec] using the Flutter standard binary encoding.
591 const StandardMethodCodec([this.messageCodec = const StandardMessageCodec()]);
592
593 /// The message codec that this method codec uses for encoding values.
594 final StandardMessageCodec messageCodec;
595
596 @override
597 ByteData encodeMethodCall(MethodCall methodCall) {
598 final WriteBuffer buffer = WriteBuffer(startCapacity: _writeBufferStartCapacity);
599 messageCodec.writeValue(buffer, methodCall.method);
600 messageCodec.writeValue(buffer, methodCall.arguments);
601 return buffer.done();
602 }
603
604 @override
605 MethodCall decodeMethodCall(ByteData? methodCall) {
606 final ReadBuffer buffer = ReadBuffer(methodCall!);
607 final Object? method = messageCodec.readValue(buffer);
608 final Object? arguments = messageCodec.readValue(buffer);
609 if (method is String && !buffer.hasRemaining) {
610 return MethodCall(method, arguments);
611 } else {
612 throw const FormatException('Invalid method call');
613 }
614 }
615
616 @override
617 ByteData encodeSuccessEnvelope(Object? result) {
618 final WriteBuffer buffer = WriteBuffer(startCapacity: _writeBufferStartCapacity);
619 buffer.putUint8(0);
620 messageCodec.writeValue(buffer, result);
621 return buffer.done();
622 }
623
624 @override
625 ByteData encodeErrorEnvelope({required String code, String? message, Object? details}) {
626 final WriteBuffer buffer = WriteBuffer(startCapacity: _writeBufferStartCapacity);
627 buffer.putUint8(1);
628 messageCodec.writeValue(buffer, code);
629 messageCodec.writeValue(buffer, message);
630 messageCodec.writeValue(buffer, details);
631 return buffer.done();
632 }
633
634 @override
635 dynamic decodeEnvelope(ByteData envelope) {
636 // First byte is zero in success case, and non-zero otherwise.
637 if (envelope.lengthInBytes == 0) {
638 throw const FormatException('Expected envelope, got nothing');
639 }
640 final ReadBuffer buffer = ReadBuffer(envelope);
641 if (buffer.getUint8() == 0) {
642 return messageCodec.readValue(buffer);
643 }
644 final Object? errorCode = messageCodec.readValue(buffer);
645 final Object? errorMessage = messageCodec.readValue(buffer);
646 final Object? errorDetails = messageCodec.readValue(buffer);
647 final String? errorStacktrace =
648 buffer.hasRemaining ? messageCodec.readValue(buffer) as String? : null;
649 if (errorCode is String &&
650 (errorMessage == null || errorMessage is String) &&
651 !buffer.hasRemaining) {
652 throw PlatformException(
653 code: errorCode,
654 message: errorMessage as String?,
655 details: errorDetails,
656 stacktrace: errorStacktrace,
657 );
658 } else {
659 throw const FormatException('Invalid envelope');
660 }
661 }
662}
663

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com