Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/e2e_tests_database.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,13 @@ jobs:
package-scope: 'firebase_database*'
native-config-args: '--database-native'
nightly_test_mode: ${{ inputs.nightly_test_mode == true }}

windows:
needs: changes
if: needs.changes.outputs.windows == 'true'
uses: ./.github/workflows/reusable_e2e_windows.yaml
with:
package-path: 'packages/firebase_database/firebase_database'
package-scope: 'firebase_database*'
native-config-args: '--database-native'
nightly_test_mode: ${{ inputs.nightly_test_mode == true }}
27 changes: 24 additions & 3 deletions packages/_flutterfire_internals/lib/src/exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ Never convertPlatformExceptionToFirebaseException(

/// Converts a [PlatformException] into a [FirebaseException].
///
/// A [PlatformException] can only be converted to a [FirebaseException] if the
/// `details` of the exception exist. Firebase returns specific codes and messages
/// which can be converted into user friendly exceptions.
/// Firebase returns specific codes and messages which can be converted into
/// user friendly exceptions. Most native implementations carry them in the
/// `details` of the exception, but a code sent as [PlatformException.code] is
/// honoured as well: the Windows plugins report native Firebase codes that way,
/// with no `details` payload at all.
FirebaseException platformExceptionToFirebaseException(
PlatformException platformException, {
required String plugin,
Expand All @@ -54,6 +56,25 @@ FirebaseException platformExceptionToFirebaseException(
message = details['message'] as String? ?? message;
} else if (rawDetails != null) {
message = rawDetails.toString();
} else if (platformException.code.isNotEmpty &&
platformException.code != plugin) {
// With no `details` payload at all, honour a code sent in the standard
// `PlatformException.code` field: the Windows plugins report native
// Firebase codes that way, because the Pigeon C++
// `FlutterError(code, message)` and `EventSink::Error(code, message)`
// overloads both send null details. Without this the code is lost and every
// such error surfaces as `unknown`.
//
// Normalised to the casing Firebase codes use, as
// `platformExceptionToFirebaseAuthException` already does for the same
// field, so that a native `UNKNOWN` or `PERMISSION_DENIED` does not leak
// through in a shape no caller can compare against.
//
// The plugin name itself is not a code - the Android and Windows Pigeon
// APIs send it in that field as a channel-level marker and carry the real
// code in `details` - so it is treated as absent rather than reported as
// `FirebaseException(code: 'firebase_database')`.
code = platformException.code.toLowerCase().replaceAll('_', '-');
}

return FirebaseException(
Expand Down
181 changes: 181 additions & 0 deletions packages/_flutterfire_internals/test/exception_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:_flutterfire_internals/_flutterfire_internals.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('platformExceptionToFirebaseException', () {
test('reads the code and message from a details map', () {
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'firebase_database',
message: 'a channel level message',
details: {
'code': 'permission-denied',
'message': "Client doesn't have permission to access the desired "
'data.',
},
),
plugin: 'firebase_database',
);

expect(exception.plugin, 'firebase_database');
expect(exception.code, 'permission-denied');
expect(
exception.message,
"Client doesn't have permission to access the desired data.",
);
});

test('keeps the platform message when details omit one', () {
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'firebase_database',
message: 'a channel level message',
details: {'code': 'permission-denied'},
),
plugin: 'firebase_database',
);

expect(exception.code, 'permission-denied');
expect(exception.message, 'a channel level message');
});

// Regression test for https://github.com/firebase/flutterfire/issues/18550:
// the Windows plugins send the native code as `PlatformException.code` with
// no details payload, and it used to be dropped in favour of `unknown`.
test('falls back to PlatformException.code when there are no details', () {
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'permission-denied',
message: "Client doesn't have permission to access the desired data.",
),
plugin: 'firebase_database',
);

expect(exception.code, 'permission-denied');
expect(
exception.message,
"Client doesn't have permission to access the desired data.",
);
});

test('normalises the casing of a PlatformException.code fallback', () {
// Keeps a native `UNKNOWN` reporting as the documented `unknown`, the
// same normalisation the auth converter applies to this field.
final exception = platformExceptionToFirebaseException(
PlatformException(code: 'PERMISSION_DENIED', message: 'denied'),
plugin: 'firebase_database',
);

expect(exception.code, 'permission-denied');

expect(
platformExceptionToFirebaseException(
PlatformException(code: 'UNKNOWN'),
plugin: 'firebase_crashlytics',
).code,
'unknown',
);
});

test('ignores PlatformException.code when details are present', () {
// Pigeon's generic error path sends the exception class name as the code
// and a stack trace as the details, so a details payload without a code
// means there is no Firebase code to report.
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'DatabaseException',
message: 'Firebase Database error: Permission denied',
details: 'a stack trace',
),
plugin: 'firebase_database',
);

expect(exception.code, 'unknown');
expect(exception.message, 'a stack trace');
});

test('prefers the details code over PlatformException.code', () {
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'firebase_database',
details: {'code': 'permission-denied'},
),
plugin: 'firebase_database',
);

expect(exception.code, 'permission-denied');
});

test('does not report the plugin name as a code', () {
// The Android and Windows Pigeon APIs put the plugin name in
// `PlatformException.code` as a channel-level marker.
final exception = platformExceptionToFirebaseException(
PlatformException(
code: 'firebase_database',
message: 'Firebase Database error: Permission denied',
),
plugin: 'firebase_database',
);

expect(exception.code, 'unknown');
expect(exception.message, 'Firebase Database error: Permission denied');
});

test('falls back to unknown when no code is available at all', () {
final exception = platformExceptionToFirebaseException(
PlatformException(code: '', message: 'no code anywhere'),
plugin: 'firebase_database',
);

expect(exception.code, 'unknown');
expect(exception.message, 'no code anywhere');
});

test('stringifies non-map details into the message', () {
final exception = platformExceptionToFirebaseException(
PlatformException(code: 'unavailable', details: 'a string detail'),
plugin: 'firebase_database',
);

expect(exception.code, 'unknown');
expect(exception.message, 'a string detail');
});
});

group('convertPlatformExceptionToFirebaseException', () {
test('converts a PlatformException and preserves the stack trace', () {
final stackTrace = StackTrace.current;

try {
convertPlatformExceptionToFirebaseException(
PlatformException(code: 'permission-denied', message: 'denied'),
stackTrace,
plugin: 'firebase_database',
);
} on FirebaseException catch (error, stack) {
expect(error.code, 'permission-denied');
expect(error.message, 'denied');
expect(stack, stackTrace);
}
});

test('rethrows exceptions that are not PlatformExceptions', () {
final error = StateError('not a platform exception');

expect(
() => convertPlatformExceptionToFirebaseException(
error,
StackTrace.current,
plugin: 'firebase_database',
),
throwsA(same(error)),
);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,25 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat
}
}

/**
* Wraps a failed write in a [FlutterError] that carries the Realtime Database code in its
* details, the way the transaction path already does.
*
* `FlutterError("firebase_database", message, null)` drops the code: the Dart converter reads it
* out of the details map, so every failure used to reach Dart as `unknown`.
*/
private fun writeFlutterError(exception: Exception?): FlutterError {
val databaseException =
when (exception) {
null -> FlutterFirebaseDatabaseException.unknown()
is FlutterFirebaseDatabaseException -> exception
is DatabaseException -> FlutterFirebaseDatabaseException.fromDatabaseException(exception)
else -> FlutterFirebaseDatabaseException.fromException(exception)
}
return FlutterError(
"firebase_database", databaseException.message, databaseException.additionalData)
}

override fun databaseReferenceSet(
app: DatabasePigeonFirebaseApp,
request: DatabaseReferenceRequest,
Expand All @@ -623,9 +642,7 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat
if (completedTask.isSuccessful) {
callback(KotlinResult.success(Unit))
} else {
val exception = completedTask.exception ?: Exception("Unknown error setting value")
callback(
KotlinResult.failure(FlutterError("firebase_database", exception.message, null)))
callback(KotlinResult.failure(writeFlutterError(completedTask.exception)))
}
}
}
Expand Down Expand Up @@ -665,10 +682,7 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat
if (completedTask.isSuccessful) {
callback(KotlinResult.success(Unit))
} else {
val exception =
completedTask.exception ?: Exception("Unknown error setting value with priority")
callback(
KotlinResult.failure(FlutterError("firebase_database", exception.message, null)))
callback(KotlinResult.failure(writeFlutterError(completedTask.exception)))
}
}
}
Expand All @@ -691,8 +705,7 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat
if (task.isSuccessful) {
callback(KotlinResult.success(Unit))
} else {
val exception = task.exception
callback(KotlinResult.failure(FlutterError("firebase_database", exception?.message, null)))
callback(KotlinResult.failure(writeFlutterError(task.exception)))
}
}
}
Expand Down
Loading
Loading