diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..eba9c009 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + push: + branches: [ master ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Restore Auth (net9.0 only) + run: dotnet restore src/Auth/Auth.csproj -p:TargetFramework=net9.0 + - name: Restore unit tests (net9.0 only) + run: dotnet restore tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj -p:TargetFramework=net9.0 + - name: Build Auth (net9.0 only) + run: dotnet build src/Auth/Auth.csproj -c Release -f net9.0 --no-restore + - name: Build unit tests + run: dotnet build tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj -c Release --no-restore + - name: Run unit tests + run: dotnet test tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj -c Release --no-build --no-restore diff --git a/.github/workflows/publish-github-packages.yml b/.github/workflows/publish-github-packages.yml new file mode 100644 index 00000000..2e34a69c --- /dev/null +++ b/.github/workflows/publish-github-packages.yml @@ -0,0 +1,69 @@ +name: publish-github-packages + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + pack-and-publish: + runs-on: macos-latest + env: + DOTNET_MULTILEVEL_LOOKUP: 0 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_WORKLOAD_UPDATE_NOTIFICATION_LEVEL: Disable + strategy: + matrix: + project: + - src/Analytics/Analytics.csproj + - src/Auth/Auth.csproj + - src/CloudMessaging/CloudMessaging.csproj + - src/Core/Core.csproj + - src/Crashlytics/Crashlytics.csproj + - src/Firestore/Firestore.csproj + - src/Functions/Functions.csproj + - src/RemoteConfig/RemoteConfig.csproj + - src/Storage/Storage.csproj + - src/Bundled/Bundled.csproj + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine package version + id: version + shell: bash + run: | + if [[ "${{ github.ref }}" =~ ^refs/tags/v ]]; then + REF="${{ github.ref }}" + VERSION="${REF#refs/tags/v}" + else + CSPROJ="src/Core/Core.csproj" + BASE_VERSION=$(grep -oE '[^<]+' "$CSPROJ" | head -n 1 | sed 's///' || echo "0.1.0") + COMMIT_SHORT=$(git rev-parse --short HEAD) + RUN_NUMBER="${{ github.run_number }}" + VERSION="${BASE_VERSION}-prerelease.${RUN_NUMBER}.${COMMIT_SHORT}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Package version: ${VERSION}" + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Install workloads + shell: bash + run: | + dotnet workload install ios android + - name: Restore + run: dotnet restore ${{ matrix.project }} + - name: Pack + run: dotnet pack ${{ matrix.project }} -c Release -o artifacts --no-restore /p:PackageVersion="${{ steps.version.outputs.version }}" + - name: Push to GitHub Packages + run: dotnet nuget push "artifacts/*.nupkg" --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --api-key "${{ secrets.GITHUB_TOKEN }}" --skip-duplicate diff --git a/.github/workflows/sample-android.yml b/.github/workflows/sample-android.yml new file mode 100644 index 00000000..a8ef74bd --- /dev/null +++ b/.github/workflows/sample-android.yml @@ -0,0 +1,26 @@ +name: sample-android (manual) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_sample_android: + runs-on: ubuntu-latest + env: + DOTNET_MULTILEVEL_LOOKUP: 0 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_WORKLOAD_UPDATE_NOTIFICATION_LEVEL: Disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Install Android workload + run: dotnet workload install android + - name: Build Playground (Android) + run: dotnet build sample/Playground/Playground.csproj -c Debug -f net9.0-android diff --git a/.github/workflows/sample-ios.yml b/.github/workflows/sample-ios.yml new file mode 100644 index 00000000..66391c97 --- /dev/null +++ b/.github/workflows/sample-ios.yml @@ -0,0 +1,31 @@ +name: sample-ios (manual) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_sample_ios: + runs-on: macos-latest + env: + DOTNET_MULTILEVEL_LOOKUP: 0 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true + DOTNET_CLI_WORKLOAD_UPDATE_NOTIFICATION_LEVEL: Disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Install iOS workload + run: dotnet workload install ios + - name: Build Playground (iOS simulator, no signing) + run: | + dotnet build sample/Playground/Playground.csproj \ + -c Debug \ + -f net9.0-ios \ + -p:RuntimeIdentifier=iossimulator-arm64 \ + -p:EnableCodeSigning=false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ec62d706 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Plugin.Firebase — AI Collaboration Guide + +This repository is public open source. Keep all contributions generic and reusable. +Do **not** include private app details, proprietary business context, or secrets. + +## Repository map +- `src/`: Package implementations (multi-targeted `net9.0`, `net9.0-android`, `net9.0-ios`) +- `docs/`: Feature docs and setup guides +- `sample/`: MAUI sample app +- `tests/`: Integration test harness (runs on device) + +## Guardrails +- Follow `.editorconfig` formatting rules. +- Run `dotnet format Plugin.Firebase.sln` before PRs. +- Avoid committing secrets (Firebase config files, signing keys, tokens). +- Keep changes minimal and scoped; update docs when behavior changes. +- Do not introduce app-specific assumptions. + +## Build & test (quick) +- Restore: `dotnet restore Plugin.Firebase.sln` +- Build (all TFMs): `dotnet build Plugin.Firebase.sln -c Release` +- Build only `net9.0` (no mobile workloads): `dotnet build src/Auth/Auth.csproj -c Release -f net9.0` +- Unit tests: `dotnet test tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj` +- Integration tests are device-only. See `BUILDING.md`. + +## Packaging & CI +- Packaging guidelines: `docs/packaging-github-packages.md` +- CI guidance: `docs/ci-cd.md` +- Workflows: `.github/workflows/ci.yml`, `.github/workflows/publish-github-packages.yml` + +## AI workflow +1) Read the relevant docs in `docs/` plus the target feature area in `src/`. +2) Propose a small, verifiable change set before editing code. +3) Keep public API changes explicit and documented. diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 00000000..9564d789 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,43 @@ +# Building Plugin.Firebase + +## Prerequisites +- .NET SDK version matching `global.json` +- Android workload (for `net9.0-android`) +- iOS workload + Xcode (for `net9.0-ios`, macOS only) + +Install workloads (if needed): +``` +dotnet workload install android ios +``` + +## Restore & build +``` +dotnet restore Plugin.Firebase.sln +dotnet build Plugin.Firebase.sln -c Release +``` + +Note: building the full solution includes the sample and integration test apps. +Running those apps requires `GoogleService-Info.plist` and `google-services.json` files (not committed). +If you don’t have local Firebase configs, use the `net9.0` build below to validate the packages. + +### Build without mobile workloads +If you want to validate core code without Android/iOS toolchains: +``` +dotnet build src/Auth/Auth.csproj -c Release -f net9.0 +``` + +## Tests (integration) +Tests live under `tests/Plugin.Firebase.IntegrationTests` and run on a real device. +You must supply your own Firebase config files (not committed): +- `GoogleService-Info.plist` (iOS) +- `google-services.json` (Android) + +Run: +``` +dotnet test tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj --no-build +``` + +## Formatting +``` +dotnet format Plugin.Firebase.sln +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7e3d65d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to Plugin.Firebase + +Thanks for contributing! This repo is public OSS. Please keep contributions general and reusable. + +## Quick start +1) Fork the repo and create a feature branch. +2) Make focused changes. +3) Run formatting and relevant builds. +4) Open a PR with a clear description. + +## Development guidelines +- Follow `.editorconfig` rules. +- Format code before PRs: `dotnet format Plugin.Firebase.sln` +- Avoid app-specific or proprietary context in code/docs. +- Never commit secrets (Firebase configs, signing keys, tokens). +- Keep API changes backward-compatible when possible. + +## Versioning +Package versions are defined per project (`src/*/*.csproj` as `PackageVersion`). +If you change package APIs, ensure the version bump is consistent across affected packages. + +### Release process (recommended) +This repo uses lockstep package versioning (all `src/*/*.csproj` share the same `PackageVersion`). + +When preparing a release: +1) Bump `PackageVersion` in **all** projects under `src/*/*.csproj`. + - Use **patch** (`4.0.0` → `4.0.1`) for bugfixes and backward-compatible improvements. + - Use **minor** (`4.0.0` → `4.1.0`) for backward-compatible feature additions. + - Use **major** (`4.x` → `5.0.0`) for breaking changes. +2) Update release notes in the relevant docs under `docs/` (e.g. `docs/auth.md`). +3) Merge to `develop`, then push a tag `vX.Y.Z` to trigger publishing to GitHub Packages. + +Notes: +- The publish workflow derives the NuGet package version from the `vX.Y.Z` tag, so tags should match `PackageVersion`. +- For manual prerelease runs, use the workflow's `workflow_dispatch` trigger. + +## Testing +Integration tests require a real device and local Firebase config files. +See `BUILDING.md` for setup and test commands. + +## Formatting only modified files +Format all files changed on the current branch (compared to `origin/master`): +``` +dotnet format Plugin.Firebase.sln --include $(git diff --name-only origin/master...HEAD) +``` + +Format only staged files (not yet committed): +``` +dotnet format Plugin.Firebase.sln --include $(git diff --name-only --cached) +``` + +## Documentation +If you change behavior or APIs, update the relevant docs in `docs/` and/or `README.md`. diff --git a/Plugin.Firebase.sln b/Plugin.Firebase.sln index acea2b28..e21f708b 100644 --- a/Plugin.Firebase.sln +++ b/Plugin.Firebase.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Storage", "src\Storage\Stor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugin.Firebase.IntegrationTests", "tests\Plugin.Firebase.IntegrationTests\Plugin.Firebase.IntegrationTests.csproj", "{9426163C-B64D-4079-8C79-2E7D3C1105A1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugin.Firebase.UnitTests", "tests\Plugin.Firebase.UnitTests\Plugin.Firebase.UnitTests.csproj", "{8C2C6C9C-9A4B-4A93-9C6D-8B4E6D36F2B7}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Features", "Features", "{FD5845E5-6DE7-456C-AEF3-FCF0F678B7AB}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playground", "sample\Playground\Playground.csproj", "{69175BBF-DEAF-4CB3-AF0B-913C67B5F1CF}" @@ -53,6 +55,10 @@ Global {9426163C-B64D-4079-8C79-2E7D3C1105A1}.Debug|Any CPU.Build.0 = Debug|Any CPU {9426163C-B64D-4079-8C79-2E7D3C1105A1}.Release|Any CPU.ActiveCfg = Release|Any CPU {9426163C-B64D-4079-8C79-2E7D3C1105A1}.Release|Any CPU.Build.0 = Release|Any CPU + {8C2C6C9C-9A4B-4A93-9C6D-8B4E6D36F2B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C2C6C9C-9A4B-4A93-9C6D-8B4E6D36F2B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C2C6C9C-9A4B-4A93-9C6D-8B4E6D36F2B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C2C6C9C-9A4B-4A93-9C6D-8B4E6D36F2B7}.Release|Any CPU.Build.0 = Release|Any CPU {69175BBF-DEAF-4CB3-AF0B-913C67B5F1CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {69175BBF-DEAF-4CB3-AF0B-913C67B5F1CF}.Debug|Any CPU.Build.0 = Debug|Any CPU {69175BBF-DEAF-4CB3-AF0B-913C67B5F1CF}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/README.md b/README.md index 25d2b9b2..fbc4f54e 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Visual Studio and Windows both have issues with long paths. The issue is explain ## Contributions -You are welcome to contribute to this project by creating a [Pull Request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests). The project contains an .editorconfig file that handles the code formatting, so please apply the formatting rules by running `dotnet format src/Plugin.Firebase.sln` in the console before creating a Pull Request (see [dotnet-format docs](https://github.com/dotnet/format) or [this video](https://www.youtube.com/watch?v=lGvP9SZ98vM&t) for more information). +You are welcome to contribute to this project by creating a [Pull Request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests). See `CONTRIBUTING.md` and `BUILDING.md` for contribution and build guidance. The project contains an .editorconfig file that handles the code formatting, so please apply the formatting rules by running `dotnet format Plugin.Firebase.sln` in the console before creating a Pull Request (see [dotnet-format docs](https://github.com/dotnet/format) or [this video](https://www.youtube.com/watch?v=lGvP9SZ98vM&t) for more information). ## License diff --git a/docs/auth.md b/docs/auth.md index a95a9e80..22691304 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -53,7 +53,7 @@ You can use [Firebase Authentication](https://firebase.google.com/docs/auth) to ![firestore_poco.png](../art/project_settings_sha1.png) - Call `FirebaseAuthImplementation.HandleActivityResultAsync(requestCode, resultCode, data);` from `MainActivity.OnActivityResult(...)` -- If you are on version 2.0.5 or later, add the following package to your project's `.csproj` file to prevent build errors: +- If you are on version 2.0.5 or later, you may want add the following package to your project's `.csproj` file to prevent build errors: ```xml ``` @@ -70,7 +70,24 @@ Since code should be documenting itself you can also take a look at the followin - [tests/.../AuthFixture.cs](https://github.com/TobiasBuchholz/Plugin.Firebase/blob/master/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs) - [sample/.../AuthService.cs](https://github.com/TobiasBuchholz/Plugin.Firebase/blob/master/sample/Playground/Common/Services/Auth/AuthService.cs) +## Error handling + +Most Auth operations can throw `FirebaseAuthException`. +The exception contains: +- `Reason`: a normalized enum for common Auth error cases +- `ErrorCode`: the raw Firebase error code (useful for platform-specific handling) +- `Email`: populated for account-collision cases when available + ## Release notes +- Version 4.0.1 + - Improve `FirebaseAuthException` mapping and expose raw error details (`ErrorCode`, `Email`, and native error metadata) to support robust UI handling. +- Version 4.0.0 + - Upgrade baseline to **.NET 9+**. + - Remove MAUI-specific dependencies (Auth remains usable from non-MAUI mobile .NET projects). + - Android initialization now requires an `ActivityLocator` function (e.g. `() => Platform.CurrentActivity`). + - Raise minimum platform versions (iOS 15+, Android 23+). + - Raise minimum Firebase SDK versions (iOS 12.5+, Android BoM 33.0+). + - Remove built-in Auth provider implementations (Facebook/Google/Apple); providers must be implemented directly via native SDKs per platform. - Version 3.1.2 - Fix NRE with Google Auth when cancelling sign in (#500) - Version 3.1.1 diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 00000000..ebdfb44b --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,37 @@ +# CI/CD Guidance + +This repo targets multiple TFMs (`net9.0`, `net9.0-android`, `net9.0-ios`). CI can be kept +lightweight by building `net9.0` only, and optionally adding Android/iOS builds on macOS. + +## Suggested CI stages +1) **Restore + build (net9.0 only)** + Fast validation on Linux/Windows runners. + +2) **Unit tests (net9.0)** + Run lightweight unit tests for core mappings and helpers. + +3) **Android build (optional)** + Requires Android SDK and `dotnet workload install android`. + +4) **iOS build (optional, macOS)** + Requires Xcode + iOS workload. + +5) **Integration tests (manual/device)** + Run on real devices using local Firebase configs. + +## Example GitHub Actions steps (snippet) +``` +- uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' +- run: dotnet restore Plugin.Firebase.sln +- run: dotnet build src/Auth/Auth.csproj -c Release -f net9.0 +- run: dotnet test tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj -c Release --no-build +``` + +## Publishing +Use `docs/packaging-github-packages.md` for packaging and push steps. + +## Repository workflows +- CI (build + unit tests): `.github/workflows/ci.yml` +- Publish to GitHub Packages (manual): `.github/workflows/publish-github-packages.yml` diff --git a/docs/packaging-github-packages.md b/docs/packaging-github-packages.md new file mode 100644 index 00000000..6cb2e058 --- /dev/null +++ b/docs/packaging-github-packages.md @@ -0,0 +1,34 @@ +# Packaging to GitHub Packages (NuGet) + +This repo ships multiple NuGet packages (one per feature + bundled). Packaging requires +Android/iOS workloads if you pack multi-targeted TFMs. + +## Pack +Pack a single package: +``` +dotnet pack src/Auth/Auth.csproj -c Release -o artifacts +``` + +Pack all packages: +``` +dotnet pack Plugin.Firebase.sln -c Release -o artifacts +``` + +## Push to GitHub Packages +Create a GitHub Packages feed in your account and authenticate with a PAT or `GITHUB_TOKEN`. +``` +dotnet nuget push "artifacts/*.nupkg" \ + --source "https://nuget.pkg.github.com//index.json" \ + --api-key "" \ + --skip-duplicate +``` + +## CI publish (recommended) +This repo includes a publish workflow that can be triggered by tags: +- Push a tag like `v4.0.1` to publish packages with version `4.0.1`. +- Use `workflow_dispatch` for manual prerelease runs. + +Notes: +- Do **not** commit tokens or config files. +- Use `--skip-duplicate` for reruns. +- Keep package versions consistent across affected projects. diff --git a/sample/Playground/Playground.csproj b/sample/Playground/Playground.csproj index 29506438..a0a8c172 100644 --- a/sample/Playground/Playground.csproj +++ b/sample/Playground/Playground.csproj @@ -84,15 +84,15 @@ - + - + true - \ No newline at end of file + diff --git a/src/Analytics/Analytics.csproj b/src/Analytics/Analytics.csproj index 26a16c9e..9422750c 100644 --- a/src/Analytics/Analytics.csproj +++ b/src/Analytics/Analytics.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.Analytics - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Auth/Auth.csproj b/src/Auth/Auth.csproj index 80f71e4d..7ba898a6 100644 --- a/src/Auth/Auth.csproj +++ b/src/Auth/Auth.csproj @@ -20,9 +20,9 @@ $(DefineConstants); - - Plugin.Firebase.Auth - 4.0.0 + + Plugin.Firebase.Auth + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Auth/Platforms/Android/FirebaseAuthExceptionFactory.cs b/src/Auth/Platforms/Android/FirebaseAuthExceptionFactory.cs new file mode 100644 index 00000000..8d8f7067 --- /dev/null +++ b/src/Auth/Platforms/Android/FirebaseAuthExceptionFactory.cs @@ -0,0 +1,29 @@ +using Firebase.Auth; +using CrossFirebaseAuthException = Plugin.Firebase.Core.Exceptions.FirebaseAuthException; +using FirebaseAuthExceptionNative = Firebase.Auth.FirebaseAuthException; + +namespace Plugin.Firebase.Auth.Platforms.Android; + +internal static class FirebaseAuthExceptionFactory +{ + public static CrossFirebaseAuthException Create(Exception exception) + { + if(exception is CrossFirebaseAuthException crossException) { + return crossException; + } + + if(exception is FirebaseAuthExceptionNative firebaseException) { + var email = (exception as FirebaseAuthUserCollisionException)?.Email; + return CrossFirebaseAuthException.FromErrorCode( + firebaseException.ErrorCode, + firebaseException.Message, + exception, + email: email); + } + + return CrossFirebaseAuthException.FromErrorCode( + errorCode: null, + message: exception.Message, + inner: exception); + } +} \ No newline at end of file diff --git a/src/Auth/Platforms/Android/FirebaseAuthImplementation.cs b/src/Auth/Platforms/Android/FirebaseAuthImplementation.cs index f7960cf0..08bc3710 100644 --- a/src/Auth/Platforms/Android/FirebaseAuthImplementation.cs +++ b/src/Auth/Platforms/Android/FirebaseAuthImplementation.cs @@ -29,112 +29,71 @@ public FirebaseAuthImplementation() public async Task VerifyPhoneNumberAsync(string phoneNumber) { - try { - var activityLocator = CrossFirebase.ActivityLocator; - if(activityLocator is null) { - throw new InvalidOperationException("ActivityLocator is null."); - } - var activity = activityLocator(); - if(activity is null) { - throw new InvalidOperationException("Activity is null."); - } - - await _phoneNumberAuth.VerifyPhoneNumberAsync(activity, phoneNumber); - } catch(Exception e) { - throw GetFirebaseAuthException(e); + var activityLocator = CrossFirebase.ActivityLocator; + if(activityLocator is null) { + throw new InvalidOperationException("ActivityLocator is null."); + } + var activity = activityLocator(); + if(activity is null) { + throw new InvalidOperationException("Activity is null."); } - } - private static CrossFirebaseAuthException GetFirebaseAuthException(Exception ex) - { - return ex switch { - FirebaseAuthEmailException => new CrossFirebaseAuthException(FIRAuthError.InvalidEmail, ex.Message), - FirebaseAuthInvalidUserException => new CrossFirebaseAuthException(FIRAuthError.UserNotFound, ex.Message), - FirebaseAuthWeakPasswordException => new CrossFirebaseAuthException(FIRAuthError.WeakPassword, ex.Message), - FirebaseAuthInvalidCredentialsException { ErrorCode: "ERROR_WRONG_PASSWORD" } => new CrossFirebaseAuthException(FIRAuthError.WrongPassword, ex.Message), - FirebaseAuthInvalidCredentialsException => new CrossFirebaseAuthException(FIRAuthError.InvalidCredential, ex.Message), - FirebaseAuthUserCollisionException { ErrorCode: "ERROR_EMAIL_ALREADY_IN_USE" } => new CrossFirebaseAuthException(FIRAuthError.EmailAlreadyInUse, ex.Message), - FirebaseAuthUserCollisionException { ErrorCode: "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" } => new CrossFirebaseAuthException(FIRAuthError.AccountExistsWithDifferentCredential, ex.Message), - _ => new CrossFirebaseAuthException(FIRAuthError.Undefined, ex.Message) - }; + await WrapAsync(_phoneNumberAuth.VerifyPhoneNumberAsync(activity, phoneNumber)); } + private static CrossFirebaseAuthException GetFirebaseAuthException(Exception ex) => + Plugin.Firebase.Auth.Platforms.Android.FirebaseAuthExceptionFactory.Create(ex); + public async Task SignInWithCustomTokenAsync(string token) { - try { - var authResult = await _firebaseAuth.SignInWithCustomTokenAsync(token); - return authResult.User.ToAbstract(authResult.AdditionalUserInfo); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + var authResult = await WrapAsync(_firebaseAuth.SignInWithCustomTokenAsync(token)); + return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task SignInWithPhoneNumberVerificationCodeAsync(string verificationCode) { - try { - var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); - return await SignInWithCredentialAsync(credential); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); + return await SignInWithCredentialAsync(credential); } private async Task SignInWithCredentialAsync(AuthCredential credential) { - var authResult = await _firebaseAuth.SignInWithCredentialAsync(credential); + var authResult = await WrapAsync(_firebaseAuth.SignInWithCredentialAsync(credential)); return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task SignInWithEmailAndPasswordAsync(string email, string password, bool createsUserAutomatically = true) { + var credential = await _emailAuth.GetCredentialAsync(email, password); try { - var credential = await _emailAuth.GetCredentialAsync(email, password); return await SignInWithCredentialAsync(credential); - } catch(Exception e) { - if(e is FirebaseAuthInvalidUserException && createsUserAutomatically) { - return await CreateUserAsync(email, password); - } - throw GetFirebaseAuthException(e); + } catch(CrossFirebaseAuthException e) when( + e.Reason == FIRAuthError.UserNotFound && createsUserAutomatically) { + return await CreateUserAsync(email, password); } } public async Task CreateUserAsync(string email, string password) { - try { - return await _emailAuth.CreateUserAsync(email, password); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + return await WrapAsync(_emailAuth.CreateUserAsync(email, password)); } public async Task SignInWithEmailLinkAsync(string email, string link) { - try { - await _firebaseAuth.SignInWithEmailLink(email, link); - return _firebaseAuth.CurrentUser.ToAbstract(); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + await WrapAsync(_firebaseAuth.SignInWithEmailLink(email, link)); + return _firebaseAuth.CurrentUser.ToAbstract(); } public async Task SignInAnonymouslyAsync() { - try { - var authResult = await _firebaseAuth.SignInAnonymouslyAsync(); - return authResult.User.ToAbstract(authResult.AdditionalUserInfo); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + var authResult = await WrapAsync(_firebaseAuth.SignInAnonymouslyAsync()); + return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task LinkWithPhoneNumberVerificationCodeAsync(string verificationCode) { - try { - var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); - return await LinkWithCredentialAsync(credential); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); + return await LinkWithCredentialAsync(credential); } private async Task LinkWithCredentialAsync(AuthCredential credential) @@ -143,27 +102,19 @@ private async Task LinkWithCredentialAsync(AuthCredential credent if(currentUser is null) { throw new FirebaseException("CurrentUser is null. You need to be logged in to use this feature."); } - var authResult = await currentUser.LinkWithCredentialAsync(credential); + var authResult = await WrapAsync(currentUser.LinkWithCredentialAsync(credential)); return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task LinkWithEmailAndPasswordAsync(string email, string password) { - try { - var credential = await _emailAuth.GetCredentialAsync(email, password); - return await LinkWithCredentialAsync(credential); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + var credential = await _emailAuth.GetCredentialAsync(email, password); + return await LinkWithCredentialAsync(credential); } public async Task SendSignInLink(string toEmail, CrossActionCodeSettings actionCodeSettings) { - try { - await _firebaseAuth.SendSignInLinkToEmail(toEmail, actionCodeSettings.ToNative()); - } catch(Exception e) { - throw GetFirebaseAuthException(e); - } + await WrapAsync(_firebaseAuth.SendSignInLinkToEmail(toEmail, actionCodeSettings.ToNative())); } public Task SignOutAsync() @@ -185,7 +136,7 @@ public bool IsSignInWithEmailLink(string link) } } - public Task SendPasswordResetEmailAsync() + public async Task SendPasswordResetEmailAsync() { var currentUser = _firebaseAuth.CurrentUser; if(currentUser is null) { @@ -197,19 +148,19 @@ public Task SendPasswordResetEmailAsync() throw new FirebaseException("CurrentUser.Email is null."); } - return _firebaseAuth.SendPasswordResetEmailAsync(email); + await WrapAsync(_firebaseAuth.SendPasswordResetEmailAsync(email)); } - public Task SendPasswordResetEmailAsync(string email) + public async Task SendPasswordResetEmailAsync(string email) { - return _firebaseAuth.SendPasswordResetEmailAsync(email); + await WrapAsync(_firebaseAuth.SendPasswordResetEmailAsync(email)); } public void UseEmulator(string host, int port) { _firebaseAuth.UseEmulator(host, port); } - + public IDisposable AddAuthStateListener(Action listener) { var authStateListener = new AuthStateListener(_ => listener.Invoke(this)); @@ -218,7 +169,43 @@ public IDisposable AddAuthStateListener(Action listener) } public IFirebaseUser CurrentUser => _firebaseAuth.CurrentUser?.ToAbstract(); - + + private static async Task WrapAsync(Task task) + { + try { + await task.ConfigureAwait(false); + } catch(Exception e) { + throw GetFirebaseAuthException(e); + } + } + + private static async Task WrapAsync(Task task) + { + try { + return await task.ConfigureAwait(false); + } catch(Exception e) { + throw GetFirebaseAuthException(e); + } + } + + private static async Task WrapAsync(global::Android.Gms.Tasks.Task task) + { + try { + await task.AsAsync().ConfigureAwait(false); + } catch(Exception e) { + throw GetFirebaseAuthException(e); + } + } + + private static async Task WrapAsync(global::Android.Gms.Tasks.Task task) where T : Java.Lang.Object + { + try { + return await task.AsAsync().ConfigureAwait(false); + } catch(Exception e) { + throw GetFirebaseAuthException(e); + } + } + private class AuthStateListener : Java.Lang.Object, FirebaseAuth.IAuthStateListener { private readonly Action _onAuthStateChanged; @@ -227,10 +214,10 @@ public AuthStateListener(Action onAuthStateChanged) { _onAuthStateChanged = onAuthStateChanged; } - + public void OnAuthStateChanged(FirebaseAuth auth) { _onAuthStateChanged.Invoke(auth); } } -} +} \ No newline at end of file diff --git a/src/Auth/Platforms/Android/FirebaseUserWrapper.cs b/src/Auth/Platforms/Android/FirebaseUserWrapper.cs index 6638607a..b29002f4 100644 --- a/src/Auth/Platforms/Android/FirebaseUserWrapper.cs +++ b/src/Auth/Platforms/Android/FirebaseUserWrapper.cs @@ -22,17 +22,18 @@ public override string ToString() public Task UpdateEmailAsync(string email) { - return _wrapped.UpdateEmailAsync(email); + return WrapAsync(_wrapped.UpdateEmailAsync(email)); } public Task UpdatePasswordAsync(string password) { - return _wrapped.UpdatePasswordAsync(password); + return WrapAsync(_wrapped.UpdatePasswordAsync(password)); } public Task UpdatePhoneNumberAsync(string verificationId, string smsCode) { - return _wrapped.UpdatePhoneNumberAsync(PhoneAuthProvider.GetCredential(verificationId, smsCode)); + return WrapAsync(_wrapped.UpdatePhoneNumberAsync( + PhoneAuthProvider.GetCredential(verificationId, smsCode))); } public Task UpdateProfileAsync(string displayName = "", string photoUrl = "") @@ -44,28 +45,64 @@ public Task UpdateProfileAsync(string displayName = "", string photoUrl = "") if(photoUrl != "") { builder.SetPhotoUri(photoUrl == null ? null : Uri.Parse(photoUrl)); } - return _wrapped.UpdateProfileAsync(builder.Build()); + return WrapAsync(_wrapped.UpdateProfileAsync(builder.Build())); } public Task SendEmailVerificationAsync(ActionCodeSettings actionCodeSettings = null) { - return _wrapped.SendEmailVerificationAsync(actionCodeSettings?.ToNative()); + return WrapAsync(_wrapped.SendEmailVerificationAsync(actionCodeSettings?.ToNative())); } public Task UnlinkAsync(string providerId) { - return _wrapped.UnlinkAsync(providerId); + return WrapAsync(_wrapped.UnlinkAsync(providerId)); } public Task DeleteAsync() { - return _wrapped.DeleteAsync(); + return WrapAsync(_wrapped.DeleteAsync()); } public async Task GetIdTokenResultAsync(bool forceRefresh = false) { - var result = (await _wrapped.GetIdToken(forceRefresh)).JavaCast(); - return result.ToAbstract(); + var result = await WrapAsync(_wrapped.GetIdToken(forceRefresh)); + return result.JavaCast().ToAbstract(); + } + + private static async Task WrapAsync(Task task) + { + try { + await task.ConfigureAwait(false); + } catch(Exception ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } + } + + private static async Task WrapAsync(Task task) + { + try { + return await task.ConfigureAwait(false); + } catch(Exception ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } + } + + private static async Task WrapAsync(global::Android.Gms.Tasks.Task task) + { + try { + await task.AsAsync().ConfigureAwait(false); + } catch(Exception ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } + } + + private static async Task WrapAsync(global::Android.Gms.Tasks.Task task) where T : Java.Lang.Object + { + try { + return await task.AsAsync().ConfigureAwait(false); + } catch(Exception ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } } public string Uid => _wrapped.Uid; diff --git a/src/Auth/Platforms/iOS/FirebaseAuthExceptionFactory.cs b/src/Auth/Platforms/iOS/FirebaseAuthExceptionFactory.cs new file mode 100644 index 00000000..44914d25 --- /dev/null +++ b/src/Auth/Platforms/iOS/FirebaseAuthExceptionFactory.cs @@ -0,0 +1,52 @@ +using Foundation; +using Plugin.Firebase.Core.Exceptions; + +namespace Plugin.Firebase.Auth.Platforms.iOS; + +internal static class FirebaseAuthExceptionFactory +{ + private const string ErrorNameKey = "FIRAuthErrorUserInfoNameKey"; + private const string ErrorEmailKey = "FIRAuthErrorUserInfoEmailKey"; + + public static FirebaseAuthException Create(NSErrorException exception) + { + var error = exception.Error; + var errorCode = TryGetErrorName(error) ?? GetAuthErrorCodeName(error); + var email = TryGetEmail(error); + + return FirebaseAuthException.FromErrorCode( + errorCode, + error.LocalizedDescription, + exception, + error.Domain, + error.Code, + email); + } + + private static string? GetAuthErrorCodeName(NSError error) + { + var code = IntPtr.Size == 8 + ? (global::Firebase.Auth.AuthErrorCode) (nint) error.Code + : (global::Firebase.Auth.AuthErrorCode) (int) (nint) error.Code; + + return code.ToString(); + } + + private static string? TryGetErrorName(NSError error) + { + if(error.UserInfo is null) { + return null; + } + + return error.UserInfo[new NSString(ErrorNameKey)]?.ToString(); + } + + private static string? TryGetEmail(NSError error) + { + if(error.UserInfo is null) { + return null; + } + + return error.UserInfo[new NSString(ErrorEmailKey)]?.ToString(); + } +} \ No newline at end of file diff --git a/src/Auth/Platforms/iOS/FirebaseAuthImplementation.cs b/src/Auth/Platforms/iOS/FirebaseAuthImplementation.cs index 156203cf..8eb37248 100644 --- a/src/Auth/Platforms/iOS/FirebaseAuthImplementation.cs +++ b/src/Auth/Platforms/iOS/FirebaseAuthImplementation.cs @@ -31,103 +31,63 @@ public FirebaseAuthImplementation() public async Task VerifyPhoneNumberAsync(string phoneNumber) { - try { - var viewController = GetViewController(); - await _phoneNumberAuth.VerifyPhoneNumberAsync(viewController, phoneNumber); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var viewController = GetViewController(); + await WrapAsync(_phoneNumberAuth.VerifyPhoneNumberAsync(viewController, phoneNumber)); } - private static FirebaseAuthException GetFirebaseAuthException(NSErrorException ex) - { - AuthErrorCode errorCode; - if(IntPtr.Size == 8) { // 64 bits devices - errorCode = (AuthErrorCode) ex.Error.Code; - } else { // 32 bits devices - errorCode = (AuthErrorCode) (int) ex.Error.Code; - } - - Enum.TryParse(errorCode.ToString(), out FIRAuthError authError); - return new FirebaseAuthException(authError, ex.Error.LocalizedDescription); - } + private static FirebaseAuthException GetFirebaseAuthException(NSErrorException ex) => + Plugin.Firebase.Auth.Platforms.iOS.FirebaseAuthExceptionFactory.Create(ex); public async Task SignInWithCustomTokenAsync(string token) { - try { - var user = await _firebaseAuth.SignInWithCustomTokenAsync(token); - return user.User.ToAbstract(); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var user = await WrapAsync(_firebaseAuth.SignInWithCustomTokenAsync(token)); + return user.User.ToAbstract(); } public async Task SignInWithPhoneNumberVerificationCodeAsync(string verificationCode) { - try { - var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); - return await SignInWithCredentialAsync(credential); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); + return await SignInWithCredentialAsync(credential); } private async Task SignInWithCredentialAsync(AuthCredential credential) { - var authResult = await _firebaseAuth.SignInWithCredentialAsync(credential); + var authResult = await WrapAsync(_firebaseAuth.SignInWithCredentialAsync(credential)); return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task SignInWithEmailAndPasswordAsync(string email, string password, bool createsUserAutomatically = true) { + var credential = await _emailAuth.GetCredentialAsync(email, password); try { - var credential = await _emailAuth.GetCredentialAsync(email, password); return await SignInWithCredentialAsync(credential); - } catch(NSErrorException e) { - if(e.Code == (long) AuthErrorCode.UserNotFound && createsUserAutomatically) { - return await CreateUserAsync(email, password); - } - throw GetFirebaseAuthException(e); + } catch(FirebaseAuthException e) when( + e.Reason == FIRAuthError.UserNotFound && createsUserAutomatically) { + return await CreateUserAsync(email, password); } } public async Task CreateUserAsync(string email, string password) { - try { - return await _emailAuth.CreateUserAsync(email, password); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + return await WrapAsync(_emailAuth.CreateUserAsync(email, password)); } public async Task SignInWithEmailLinkAsync(string email, string link) { - try { - var authResult = await _firebaseAuth.SignInWithLinkAsync(email, link); - return authResult.User.ToAbstract(authResult.AdditionalUserInfo); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var authResult = await WrapAsync(_firebaseAuth.SignInWithLinkAsync(email, link)); + return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task SignInAnonymouslyAsync() { - try { - var authResult = await _firebaseAuth.SignInAnonymouslyAsync(); - return authResult.User.ToAbstract(authResult.AdditionalUserInfo); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var authResult = await WrapAsync(_firebaseAuth.SignInAnonymouslyAsync()); + return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task LinkWithPhoneNumberVerificationCodeAsync(string verificationCode) { - try { - var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); - return await LinkWithCredentialAsync(credential); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var credential = await _phoneNumberAuth.GetCredentialAsync(verificationCode); + return await LinkWithCredentialAsync(credential); } private async Task LinkWithCredentialAsync(AuthCredential credential) @@ -137,27 +97,19 @@ private async Task LinkWithCredentialAsync(AuthCredential credent throw new InvalidOperationException("User must be signed in to link with credential."); } - var authResult = await currentUser.LinkAsync(credential); + var authResult = await WrapAsync(currentUser.LinkAsync(credential)); return authResult.User.ToAbstract(authResult.AdditionalUserInfo); } public async Task LinkWithEmailAndPasswordAsync(string email, string password) { - try { - var credential = await _emailAuth.GetCredentialAsync(email, password); - return await LinkWithCredentialAsync(credential); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + var credential = await _emailAuth.GetCredentialAsync(email, password); + return await LinkWithCredentialAsync(credential); } public async Task SendSignInLink(string toEmail, CrossActionCodeSettings actionCodeSettings) { - try { - await _firebaseAuth.SendSignInLinkAsync(toEmail, actionCodeSettings.ToNative()); - } catch(NSErrorException e) { - throw GetFirebaseAuthException(e); - } + await WrapAsync(_firebaseAuth.SendSignInLinkAsync(toEmail, actionCodeSettings.ToNative())); } public Task SignOutAsync() @@ -166,7 +118,7 @@ public Task SignOutAsync() return error is null ? Task.CompletedTask - : throw new FirebaseException("Errored signing out", new NSErrorException(error)); + : throw GetFirebaseAuthException(new NSErrorException(error)); } public bool IsSignInWithEmailLink(string link) @@ -178,7 +130,7 @@ public bool IsSignInWithEmailLink(string link) } } - public Task SendPasswordResetEmailAsync() + public async Task SendPasswordResetEmailAsync() { var currentUser = _firebaseAuth.CurrentUser; if(currentUser is null) { @@ -186,28 +138,31 @@ public Task SendPasswordResetEmailAsync() } var email = currentUser.Email; - return email is null - ? throw new FirebaseException("CurrentUser.Email is null.") - : _firebaseAuth.SendPasswordResetAsync(email); + if(email is null) { + throw new FirebaseException("CurrentUser.Email is null."); + } + + await WrapAsync(_firebaseAuth.SendPasswordResetAsync(email)); } - public Task SendPasswordResetEmailAsync(string email) + public async Task SendPasswordResetEmailAsync(string email) { - return _firebaseAuth.SendPasswordResetAsync(email); + await WrapAsync(_firebaseAuth.SendPasswordResetAsync(email)); } public void UseEmulator(string host, int port) { _firebaseAuth.UseEmulatorWithHost(host, port); } - + public IDisposable AddAuthStateListener(Action listener) { - var handle =_firebaseAuth.AddAuthStateDidChangeListener((_, _) => listener.Invoke(this)); + var handle = _firebaseAuth.AddAuthStateDidChangeListener((_, _) => listener.Invoke(this)); return new DisposableWithAction(() => _firebaseAuth.RemoveAuthStateDidChangeListener(handle)); } - private static UIViewController GetViewController() { + private static UIViewController GetViewController() + { var windowScene = UIApplication.SharedApplication.ConnectedScenes.ToArray() .FirstOrDefault(static x => x.ActivationState == UISceneActivationState.ForegroundActive) as UIWindowScene; @@ -221,5 +176,23 @@ private static UIViewController GetViewController() { return rootViewController.PresentedViewController ?? rootViewController; } + private static async Task WrapAsync(Task task) + { + try { + await task.ConfigureAwait(false); + } catch(NSErrorException e) { + throw GetFirebaseAuthException(e); + } + } + + private static async Task WrapAsync(Task task) + { + try { + return await task.ConfigureAwait(false); + } catch(NSErrorException e) { + throw GetFirebaseAuthException(e); + } + } + public IFirebaseUser CurrentUser => _firebaseAuth.CurrentUser?.ToAbstract(); -} +} \ No newline at end of file diff --git a/src/Auth/Platforms/iOS/FirebaseUserWrapper.cs b/src/Auth/Platforms/iOS/FirebaseUserWrapper.cs index c671e6d6..48cf32b0 100644 --- a/src/Auth/Platforms/iOS/FirebaseUserWrapper.cs +++ b/src/Auth/Platforms/iOS/FirebaseUserWrapper.cs @@ -19,17 +19,18 @@ public override string ToString() public Task UpdateEmailAsync(string email) { - return _wrapped.UpdateEmailAsync(email); + return WrapAsync(_wrapped.UpdateEmailAsync(email)); } public Task UpdatePasswordAsync(string password) { - return _wrapped.UpdatePasswordAsync(password); + return WrapAsync(_wrapped.UpdatePasswordAsync(password)); } public Task UpdatePhoneNumberAsync(string verificationId, string smsCode) { - return _wrapped.UpdatePhoneNumberCredentialAsync(PhoneAuthProvider.DefaultInstance.GetCredential(verificationId, smsCode)); + return WrapAsync(_wrapped.UpdatePhoneNumberCredentialAsync( + PhoneAuthProvider.DefaultInstance.GetCredential(verificationId, smsCode))); } public Task UpdateProfileAsync(string displayName = "", string photoUrl = "") @@ -41,32 +42,51 @@ public Task UpdateProfileAsync(string displayName = "", string photoUrl = "") if(photoUrl != "") { request.PhotoUrl = photoUrl == null ? null : new NSUrl(photoUrl); } - return request.CommitChangesAsync(); + return WrapAsync(request.CommitChangesAsync()); } public Task SendEmailVerificationAsync(ActionCodeSettings actionCodeSettings = null) { - return actionCodeSettings == null - ? _wrapped.SendEmailVerificationAsync() - : _wrapped.SendEmailVerificationAsync(actionCodeSettings.ToNative()); + return WrapAsync( + actionCodeSettings == null + ? _wrapped.SendEmailVerificationAsync() + : _wrapped.SendEmailVerificationAsync(actionCodeSettings.ToNative())); } public Task UnlinkAsync(string providerId) { - return _wrapped.UnlinkAsync(providerId); + return WrapAsync(_wrapped.UnlinkAsync(providerId)); } public Task DeleteAsync() { - return _wrapped.DeleteAsync(); + return WrapAsync(_wrapped.DeleteAsync()); } public async Task GetIdTokenResultAsync(bool forceRefresh = false) { - var result = await _wrapped.GetIdTokenResultAsync(forceRefresh); + var result = await WrapAsync(_wrapped.GetIdTokenResultAsync(forceRefresh)); return result.ToAbstract(); } + private static async Task WrapAsync(Task task) + { + try { + await task.ConfigureAwait(false); + } catch(NSErrorException ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } + } + + private static async Task WrapAsync(Task task) + { + try { + return await task.ConfigureAwait(false); + } catch(NSErrorException ex) { + throw FirebaseAuthExceptionFactory.Create(ex); + } + } + public string Uid => _wrapped.Uid; public string DisplayName => _wrapped.DisplayName; public string Email => _wrapped.Email; diff --git a/src/Bundled/Bundled.csproj b/src/Bundled/Bundled.csproj index c915018e..6bb7114c 100644 --- a/src/Bundled/Bundled.csproj +++ b/src/Bundled/Bundled.csproj @@ -22,7 +22,7 @@ Plugin.Firebase - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/CloudMessaging/CloudMessaging.csproj b/src/CloudMessaging/CloudMessaging.csproj index 9e921fd9..6f48c862 100644 --- a/src/CloudMessaging/CloudMessaging.csproj +++ b/src/CloudMessaging/CloudMessaging.csproj @@ -23,7 +23,7 @@ Plugin.Firebase.CloudMessaging - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 919ab327..78d11f9e 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -4,6 +4,7 @@ net9.0;net9.0-android;net9.0-ios true enable + enable 15.0 @@ -20,9 +21,9 @@ $(DefineConstants); - - Plugin.Firebase.Core - 4.0.0 + + Plugin.Firebase.Core + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Core/Shared/Exceptions/FirebaseAuthException.cs b/src/Core/Shared/Exceptions/FirebaseAuthException.cs index 35e4b349..3d7f7dc7 100644 --- a/src/Core/Shared/Exceptions/FirebaseAuthException.cs +++ b/src/Core/Shared/Exceptions/FirebaseAuthException.cs @@ -5,65 +5,309 @@ public enum FIRAuthError /// /// Unknown error reason. /// - Undefined, + Undefined = 0, /// /// Indicates the email address is malformed. /// - InvalidEmail, + InvalidEmail = 1, /// /// Indicates the user attempted sign in with a wrong password. /// - WrongPassword, + WrongPassword = 2, /// /// Indicates an attempt to set a password that is considered too weak. /// - WeakPassword, + WeakPassword = 3, /// /// Indicates the email used to attempt sign up already exists. /// - EmailAlreadyInUse, + EmailAlreadyInUse = 4, /// /// Indicates the user account was not found. /// - UserNotFound, + UserNotFound = 5, /// /// Indicates the current user’s token has expired, for example, the user may have changed account password on another device. /// You must prompt the user to sign in again on this device. /// - UserTokenExpired, + UserTokenExpired = 6, /// /// Indicates the supplied credential is invalid. This could happen if it has expired or it is malformed. /// - InvalidCredential, + InvalidCredential = 7, /// /// Indicates the user's account is disabled. /// - UserDisabled, + UserDisabled = 8, /// - /// Indicates the user already signed in once with a trusted provider and hence, cannot sign in with untrusted provider anymore. + /// Indicates the user already signed in once with a trusted provider and hence cannot sign in with another provider. /// List of trusted and untrusted providers: https://firebase.google.com/docs/auth/users#verified_email_addresses /// - AccountExistsWithDifferentCredential + AccountExistsWithDifferentCredential = 9, + /// + /// Indicates a validation error with the custom token. + /// + InvalidCustomToken = 10, + /// + /// Indicates the service account and the API key belong to different projects. + /// + CustomTokenMismatch = 11, + /// + /// Indicates the saved auth credential is invalid. The user needs to sign in again. + /// + InvalidUserToken = 12, + /// + /// Indicates that the operation is not allowed (provider disabled or email/password not enabled). + /// + OperationNotAllowed = 13, + /// + /// Indicates that the user must reauthenticate due to a recent-login requirement. + /// + RequiresRecentLogin = 14, + /// + /// Indicates that a different user than the current user was used for reauthentication. + /// + UserMismatch = 15, + /// + /// Indicates that the provider has already been linked to the user. + /// + ProviderAlreadyLinked = 16, + /// + /// Indicates the provider is not linked to the user. + /// + NoSuchProvider = 17, + /// + /// Indicates the email asserted by a credential is already in use by another account. + /// + CredentialAlreadyInUse = 18, + /// + /// Indicates that the phone auth credential was created with an empty verification ID. + /// + MissingVerificationId = 19, + /// + /// Indicates that the phone auth credential was created with an empty verification code. + /// + MissingVerificationCode = 20, + /// + /// Indicates that the phone auth credential was created with an invalid verification code. + /// + InvalidVerificationCode = 21, + /// + /// Indicates that the phone auth credential was created with an invalid verification ID. + /// + InvalidVerificationId = 22, + /// + /// Indicates that the SMS code has expired. + /// + SessionExpired = 23, + /// + /// Indicates that the quota of SMS messages for a given project has been exceeded. + /// + QuotaExceeded = 24, + /// + /// Indicates that the APNs device token was not obtained or forwarded. + /// + MissingAppToken = 25, + /// + /// Indicates that the APNs device token was missing when required for phone auth. + /// + MissingAppCredential = 26, + /// + /// Indicates that an invalid APNs device token was used. + /// + InvalidAppCredential = 27, + /// + /// Indicates that a notification was not forwarded to Firebase Auth when required. + /// + NotificationNotForwarded = 28, + /// + /// Indicates an invalid recipient email was sent in the request. + /// + InvalidRecipientEmail = 29, + /// + /// Indicates an invalid sender email is set in the console for this action. + /// + InvalidSender = 30, + /// + /// Indicates an invalid email template for sending update email. + /// + InvalidMessagePayload = 31, + /// + /// Indicates that the iOS bundle ID is missing when required. + /// + MissingIosBundleId = 32, + /// + /// Indicates that the Android package name is missing when required. + /// + MissingAndroidPackageName = 33, + /// + /// Indicates that the domain specified in the continue URL is not allowlisted. + /// + UnauthorizedDomain = 34, + /// + /// Indicates that the domain specified in the continue URL is not valid. + /// + InvalidContinueUri = 35, + /// + /// Indicates an invalid API key was supplied in the request. + /// + InvalidApiKey = 36, + /// + /// Indicates the app is not authorized to use Firebase Authentication with the provided API key. + /// + AppNotAuthorized = 37, + /// + /// Indicates an error occurred when accessing the keychain. + /// + KeychainError = 38, + /// + /// Indicates an internal error occurred. + /// + InternalError = 39, + /// + /// Indicates a network error occurred during the operation. + /// + NetworkError = 40, + /// + /// Indicates the request has been blocked after an abnormal number of requests. + /// + TooManyRequests = 41, + /// + /// Indicates a web-based sign-in flow failed due to a web context network error. + /// + WebNetworkRequestFailed = 42 } public class FirebaseAuthException : FirebaseException { public FIRAuthError Reason { get; } + public string? ErrorCode { get; } + public string? NativeErrorDomain { get; } + public long? NativeErrorCode { get; } + public string? Email { get; } public FirebaseAuthException(FIRAuthError reason) + : this(reason, string.Empty) { - Reason = reason; } public FirebaseAuthException(FIRAuthError reason, string message) - : base(message) + : this(reason, message, null, null, null, null, null) { - Reason = reason; } public FirebaseAuthException(FIRAuthError reason, string message, Exception inner) + : this(reason, message, inner, null, null, null, null) + { + } + + public FirebaseAuthException( + FIRAuthError reason, + string message, + Exception? inner, + string? errorCode, + string? nativeErrorDomain, + long? nativeErrorCode, + string? email) : base(message, inner) { Reason = reason; + ErrorCode = errorCode; + NativeErrorDomain = nativeErrorDomain; + NativeErrorCode = nativeErrorCode; + Email = email; + } + + public static FirebaseAuthException FromErrorCode( + string? errorCode, + string? message, + Exception? inner = null, + string? nativeErrorDomain = null, + long? nativeErrorCode = null, + string? email = null) + { + var reason = MapReason(errorCode); + return new FirebaseAuthException( + reason, + message ?? string.Empty, + inner, + errorCode, + nativeErrorDomain, + nativeErrorCode, + email); + } + + private static FIRAuthError MapReason(string? errorCode) + { + if(string.IsNullOrWhiteSpace(errorCode)) { + return FIRAuthError.Undefined; + } + + var normalized = NormalizeErrorCode(errorCode); + if(IsNumericCode(normalized)) { + return FIRAuthError.Undefined; + } + + if(string.Equals(normalized, "NetworkRequestFailed", StringComparison.OrdinalIgnoreCase)) { + return FIRAuthError.NetworkError; + } + + if(Enum.TryParse(normalized, ignoreCase: true, out FIRAuthError reason) + && Enum.IsDefined(typeof(FIRAuthError), reason)) { + return reason; + } + + return FIRAuthError.Undefined; + } + + private static string NormalizeErrorCode(string errorCode) + { + var code = errorCode.Trim(); + + if(code.StartsWith("ERROR_", StringComparison.OrdinalIgnoreCase)) { + code = code.Substring("ERROR_".Length); + } + + if(code.StartsWith("FIRAuthErrorCode", StringComparison.OrdinalIgnoreCase)) { + code = code.Substring("FIRAuthErrorCode".Length); + } + + if(code.StartsWith("AuthErrorCode", StringComparison.OrdinalIgnoreCase)) { + code = code.Substring("AuthErrorCode".Length); + } + + if(code.Contains('_')) { + return ToPascalCase(code); + } + + return code; + } + + private static bool IsNumericCode(string code) + { + return long.TryParse(code, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out _); + } + + private static string ToPascalCase(string code) + { + var parts = code.Split('_', StringSplitOptions.RemoveEmptyEntries); + if(parts.Length == 0) { + return code; + } + + var buffer = new System.Text.StringBuilder(code.Length); + foreach(var part in parts) { + if(part.Length == 0) { + continue; + } + + var lower = part.ToLowerInvariant(); + buffer.Append(char.ToUpperInvariant(lower[0])); + if(lower.Length > 1) { + buffer.Append(lower.Substring(1)); + } + } + + return buffer.ToString(); } } \ No newline at end of file diff --git a/src/Core/Shared/Exceptions/FirebaseException.cs b/src/Core/Shared/Exceptions/FirebaseException.cs index 55f66e08..94263671 100644 --- a/src/Core/Shared/Exceptions/FirebaseException.cs +++ b/src/Core/Shared/Exceptions/FirebaseException.cs @@ -11,7 +11,7 @@ public FirebaseException(string message) { } - public FirebaseException(string message, Exception inner) + public FirebaseException(string message, Exception? inner) : base(message, inner) { } diff --git a/src/Core/Shared/Extensions/EnumerableExtensions.cs b/src/Core/Shared/Extensions/EnumerableExtensions.cs index f068feaa..b0877530 100644 --- a/src/Core/Shared/Extensions/EnumerableExtensions.cs +++ b/src/Core/Shared/Extensions/EnumerableExtensions.cs @@ -4,7 +4,7 @@ namespace Plugin.Firebase.Core.Extensions; public static class EnumerableExtensions { - public static bool SequenceEqualSafe(this IEnumerable @this, IEnumerable other, Func comparer = null) + public static bool SequenceEqualSafe(this IEnumerable? @this, IEnumerable? other, Func? comparer = null) { if(@this == null && other == null) { return true; @@ -17,7 +17,7 @@ public static bool SequenceEqualSafe(this IEnumerable @this, IEnumerable(this IEnumerable source, IEnumerable other, Func comparer) + public static bool SequenceEqual(this IEnumerable source, IEnumerable other, Func? comparer) { return comparer == null ? source.SequenceEqual(other) : source.SequenceEqual(other, new FuncEqualityComparer(comparer)); } diff --git a/src/Core/Shared/Utils/FuncEqualityComparer.cs b/src/Core/Shared/Utils/FuncEqualityComparer.cs index 176d330a..0bb445bd 100644 --- a/src/Core/Shared/Utils/FuncEqualityComparer.cs +++ b/src/Core/Shared/Utils/FuncEqualityComparer.cs @@ -16,8 +16,15 @@ public FuncEqualityComparer(Func comparer, Func hash) _hash = hash; } - public bool Equals(T x, T y) + public bool Equals(T? x, T? y) { + if(x is null && y is null) { + return true; + } + if(x is null || y is null) { + return false; + } + return _comparer(x, y); } diff --git a/src/Crashlytics/Crashlytics.csproj b/src/Crashlytics/Crashlytics.csproj index 2ceea65b..4a8858a9 100644 --- a/src/Crashlytics/Crashlytics.csproj +++ b/src/Crashlytics/Crashlytics.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.Crashlytics - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Firestore/Firestore.csproj b/src/Firestore/Firestore.csproj index 7d637366..40c63960 100644 --- a/src/Firestore/Firestore.csproj +++ b/src/Firestore/Firestore.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.Firestore - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Functions/Functions.csproj b/src/Functions/Functions.csproj index 520a65d3..8aceca85 100644 --- a/src/Functions/Functions.csproj +++ b/src/Functions/Functions.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.Functions - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/RemoteConfig/RemoteConfig.csproj b/src/RemoteConfig/RemoteConfig.csproj index 6b69d18d..5bf1e683 100644 --- a/src/RemoteConfig/RemoteConfig.csproj +++ b/src/RemoteConfig/RemoteConfig.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.RemoteConfig - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/src/Storage/Storage.csproj b/src/Storage/Storage.csproj index 429290b2..1cadeef7 100644 --- a/src/Storage/Storage.csproj +++ b/src/Storage/Storage.csproj @@ -22,7 +22,7 @@ Plugin.Firebase.Storage - 4.0.0 + 4.0.1 MIT https://github.com/TobiasBuchholz/Plugin.Firebase diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs index 6ae45ef3..ef674ff4 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs @@ -37,14 +37,22 @@ public async Task signs_in_user_via_email_and_password() public async Task throws_error_if_credentials_are_invalid_when_signing_in_user_via_email_and_password() { var sut = CrossFirebaseAuth.Current; - await Assert.ThrowsAnyAsync(() => sut.SignInWithEmailAndPasswordAsync("sign-in-with-pw@test.com", "000000", createsUserAutomatically: false)); + var exception = await Assert.ThrowsAnyAsync(() => + sut.SignInWithEmailAndPasswordAsync("sign-in-with-pw@test.com", "000000", createsUserAutomatically: false)); + + Assert.Equal(FIRAuthError.WrongPassword, exception.Reason); + Assert.False(string.IsNullOrWhiteSpace(exception.ErrorCode)); } [Fact] public async Task throws_error_if_user_does_not_exist_and_should_not_be_created_automatically_due_sign_in_via_email_and_password() { var sut = CrossFirebaseAuth.Current; - await Assert.ThrowsAnyAsync(() => sut.SignInWithEmailAndPasswordAsync("does-not-exist@test.com", "123456", createsUserAutomatically: false)); + var exception = await Assert.ThrowsAnyAsync(() => + sut.SignInWithEmailAndPasswordAsync("does-not-exist@test.com", "123456", createsUserAutomatically: false)); + + Assert.Equal(FIRAuthError.UserNotFound, exception.Reason); + Assert.False(string.IsNullOrWhiteSpace(exception.ErrorCode)); } [Fact] diff --git a/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj b/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj index aae507d5..8bc58649 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj +++ b/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj @@ -31,6 +31,11 @@ + + + + + @@ -51,18 +56,16 @@ - - - + - + diff --git a/tests/Plugin.Firebase.UnitTests/FirebaseAuthExceptionTests.cs b/tests/Plugin.Firebase.UnitTests/FirebaseAuthExceptionTests.cs new file mode 100644 index 00000000..fe47d3b3 --- /dev/null +++ b/tests/Plugin.Firebase.UnitTests/FirebaseAuthExceptionTests.cs @@ -0,0 +1,63 @@ +using Plugin.Firebase.Core.Exceptions; +using Xunit; + +namespace Plugin.Firebase.UnitTests; + +public sealed class FirebaseAuthExceptionTests +{ + public static IEnumerable KnownCodes => + new[] + { + new object[] { "ERROR_INVALID_EMAIL", FIRAuthError.InvalidEmail }, + new object[] { "ERROR_WRONG_PASSWORD", FIRAuthError.WrongPassword }, + new object[] { "ERROR_WEAK_PASSWORD", FIRAuthError.WeakPassword }, + new object[] { "ERROR_EMAIL_ALREADY_IN_USE", FIRAuthError.EmailAlreadyInUse }, + new object[] { "ERROR_USER_NOT_FOUND", FIRAuthError.UserNotFound }, + new object[] { "ERROR_USER_DISABLED", FIRAuthError.UserDisabled }, + new object[] { "ERROR_INVALID_CREDENTIAL", FIRAuthError.InvalidCredential }, + new object[] { "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL", FIRAuthError.AccountExistsWithDifferentCredential }, + new object[] { "ERROR_CREDENTIAL_ALREADY_IN_USE", FIRAuthError.CredentialAlreadyInUse }, + new object[] { "ERROR_REQUIRES_RECENT_LOGIN", FIRAuthError.RequiresRecentLogin }, + new object[] { "ERROR_OPERATION_NOT_ALLOWED", FIRAuthError.OperationNotAllowed }, + new object[] { "ERROR_INVALID_CUSTOM_TOKEN", FIRAuthError.InvalidCustomToken }, + new object[] { "ERROR_CUSTOM_TOKEN_MISMATCH", FIRAuthError.CustomTokenMismatch }, + new object[] { "ERROR_INVALID_USER_TOKEN", FIRAuthError.InvalidUserToken }, + new object[] { "ERROR_KEYCHAIN_ERROR", FIRAuthError.KeychainError }, + new object[] { "ERROR_INTERNAL_ERROR", FIRAuthError.InternalError }, + new object[] { "ERROR_TOO_MANY_REQUESTS", FIRAuthError.TooManyRequests }, + new object[] { "ERROR_NETWORK_REQUEST_FAILED", FIRAuthError.NetworkError }, + new object[] { "FIRAuthErrorCodeInvalidEmail", FIRAuthError.InvalidEmail }, + new object[] { "AuthErrorCodeInvalidCredential", FIRAuthError.InvalidCredential }, + }; + + [Theory] + [MemberData(nameof(KnownCodes))] + public void FromErrorCode_maps_known_codes(string code, FIRAuthError expected) + { + var exception = FirebaseAuthException.FromErrorCode(code, "message"); + Assert.Equal(expected, exception.Reason); + Assert.Equal(code, exception.ErrorCode); + } + + [Fact] + public void FromErrorCode_returns_undefined_for_unknown_code() + { + var exception = FirebaseAuthException.FromErrorCode("ERROR_SOMETHING_UNKNOWN", "message"); + Assert.Equal(FIRAuthError.Undefined, exception.Reason); + } + + [Fact] + public void FromErrorCode_preserves_metadata() + { + var exception = FirebaseAuthException.FromErrorCode( + "ERROR_WRONG_PASSWORD", + "bad password", + email: "user@test.com", + nativeErrorDomain: "domain", + nativeErrorCode: 123); + + Assert.Equal("user@test.com", exception.Email); + Assert.Equal("domain", exception.NativeErrorDomain); + Assert.Equal(123, exception.NativeErrorCode); + } +} diff --git a/tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj b/tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj new file mode 100644 index 00000000..3671a3b4 --- /dev/null +++ b/tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + false + enable + enable + + + + + + + + + + + + + + +