diff --git a/AGENTS.md b/AGENTS.md index 040fa1f3..8ef7fe18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp - `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`. - `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`. - `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first. +- **`third_party/`**: others' packages patched for us, each a pub release copied whole with only what its README names changed, overridden by path in the root `pubspec.yaml` (today the three Android plugins still applying the Kotlin Gradle Plugin themselves, which AGP 9's built-in Kotlin refuses soon); not our code, so left out of the analysis and the format check; the servers' images copy it, as pub refuses an override whose path is missing. Drop an override once its package's release has the change. - **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files join Learn's (Train's `compose.train.yaml`, Todo's `compose.todo.yaml`, each switched on in `.env`). **`snap/`**: Learn's snap, built from the root, which its build needs whole; Todo's and Train's are `apps/todo/snap/` and `apps/train/snap/`, which the release tool builds each in a clone of the repository with it in place of `snap/` (Todo's `AGENTS.md`, *Releases*). **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below). - **One workspace, one lock**: the root `pubspec.yaml` lists every package, `pubspec.lock` holds one version of each dependency for all of them, and its `dependency_overrides` hold for every app (the offline sync fork, Learn's `AGENTS.md`; `cli_util`, below). **Serverpod's version moves as one**: every package pins Serverpod's packages to the same version (4.0.4), the module's, Meditate's, Todo's and Train's too, together with the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`) and the sync packages Learn's server depends on. diff --git a/analysis_options.yaml b/analysis_options.yaml index 48395da3..f800282a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,3 +9,9 @@ include: package:leancode_lint/analysis_options.yaml plugins: leancode_lint: ^27.0.0 + +analyzer: + # Others' plugins, patched for their Gradle files alone (third_party/README.md), + # are analysed and formatted as their authors keep them. + exclude: + - third_party/** diff --git a/apps/learn/halkyon_learn_flutter/android/.gitignore b/apps/learn/halkyon_learn_flutter/android/.gitignore index 02dcee27..e9339c36 100644 --- a/apps/learn/halkyon_learn_flutter/android/.gitignore +++ b/apps/learn/halkyon_learn_flutter/android/.gitignore @@ -14,3 +14,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# The Kotlin compiler daemon's sessions. +.kotlin/ diff --git a/apps/learn/halkyon_learn_flutter/android/gradle.properties b/apps/learn/halkyon_learn_flutter/android/gradle.properties index e96108cf..b697b5db 100644 --- a/apps/learn/halkyon_learn_flutter/android/gradle.properties +++ b/apps/learn/halkyon_learn_flutter/android/gradle.properties @@ -2,5 +2,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false +# Kotlin built into the Android Gradle plugin (4 Nov 2026), every plugin +# migrated (third_party/README.md). +android.builtInKotlin=true diff --git a/apps/learn/halkyon_learn_server/Dockerfile b/apps/learn/halkyon_learn_server/Dockerfile index fa9605e6..71d12a88 100644 --- a/apps/learn/halkyon_learn_server/Dockerfile +++ b/apps/learn/halkyon_learn_server/Dockerfile @@ -13,6 +13,9 @@ FROM dart:3.13.4 AS build WORKDIR /app COPY pubspec.yaml pubspec.lock ./ COPY tool/workspace_only.sh tool/ +# The root's dependency_overrides name these by path; pub refuses a path +# that isn't there, though no server uses them. +COPY third_party third_party COPY apps/learn/halkyon_learn_server apps/learn/halkyon_learn_server COPY packages/halkyon_base_server packages/halkyon_base_server COPY packages/halkyon_base_client packages/halkyon_base_client diff --git a/apps/meditate/halkyon_meditate_flutter/android/.gitignore b/apps/meditate/halkyon_meditate_flutter/android/.gitignore index be3943c9..28f0a2a0 100644 --- a/apps/meditate/halkyon_meditate_flutter/android/.gitignore +++ b/apps/meditate/halkyon_meditate_flutter/android/.gitignore @@ -12,3 +12,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# The Kotlin compiler daemon's sessions. +.kotlin/ diff --git a/apps/meditate/halkyon_meditate_flutter/android/gradle.properties b/apps/meditate/halkyon_meditate_flutter/android/gradle.properties index e96108cf..b697b5db 100644 --- a/apps/meditate/halkyon_meditate_flutter/android/gradle.properties +++ b/apps/meditate/halkyon_meditate_flutter/android/gradle.properties @@ -2,5 +2,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false +# Kotlin built into the Android Gradle plugin (4 Nov 2026), every plugin +# migrated (third_party/README.md). +android.builtInKotlin=true diff --git a/apps/todo/halkyon_todo_flutter/android/.gitignore b/apps/todo/halkyon_todo_flutter/android/.gitignore index be3943c9..28f0a2a0 100644 --- a/apps/todo/halkyon_todo_flutter/android/.gitignore +++ b/apps/todo/halkyon_todo_flutter/android/.gitignore @@ -12,3 +12,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# The Kotlin compiler daemon's sessions. +.kotlin/ diff --git a/apps/todo/halkyon_todo_flutter/android/gradle.properties b/apps/todo/halkyon_todo_flutter/android/gradle.properties index e96108cf..b697b5db 100644 --- a/apps/todo/halkyon_todo_flutter/android/gradle.properties +++ b/apps/todo/halkyon_todo_flutter/android/gradle.properties @@ -2,5 +2,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false +# Kotlin built into the Android Gradle plugin (4 Nov 2026), every plugin +# migrated (third_party/README.md). +android.builtInKotlin=true diff --git a/apps/todo/halkyon_todo_server/Dockerfile b/apps/todo/halkyon_todo_server/Dockerfile index c578dbe3..c4be90aa 100644 --- a/apps/todo/halkyon_todo_server/Dockerfile +++ b/apps/todo/halkyon_todo_server/Dockerfile @@ -12,6 +12,9 @@ FROM dart:3.13.4 AS build WORKDIR /app COPY pubspec.yaml pubspec.lock ./ COPY tool/workspace_only.sh tool/ +# The root's dependency_overrides name these by path; pub refuses a path +# that isn't there, though no server uses them. +COPY third_party third_party COPY apps/todo/halkyon_todo_server apps/todo/halkyon_todo_server COPY packages/halkyon_base_server packages/halkyon_base_server COPY packages/halkyon_base_client packages/halkyon_base_client diff --git a/apps/train/halkyon_train_flutter/android/.gitignore b/apps/train/halkyon_train_flutter/android/.gitignore index be3943c9..28f0a2a0 100644 --- a/apps/train/halkyon_train_flutter/android/.gitignore +++ b/apps/train/halkyon_train_flutter/android/.gitignore @@ -12,3 +12,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# The Kotlin compiler daemon's sessions. +.kotlin/ diff --git a/apps/train/halkyon_train_flutter/android/.kotlin/sessions/kotlin-compiler-14235800010102095717.salive b/apps/train/halkyon_train_flutter/android/.kotlin/sessions/kotlin-compiler-14235800010102095717.salive deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/train/halkyon_train_flutter/android/gradle.properties b/apps/train/halkyon_train_flutter/android/gradle.properties index e96108cf..b697b5db 100644 --- a/apps/train/halkyon_train_flutter/android/gradle.properties +++ b/apps/train/halkyon_train_flutter/android/gradle.properties @@ -2,5 +2,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false +# Kotlin built into the Android Gradle plugin (4 Nov 2026), every plugin +# migrated (third_party/README.md). +android.builtInKotlin=true diff --git a/apps/train/halkyon_train_server/Dockerfile b/apps/train/halkyon_train_server/Dockerfile index 2d6b865a..186b6ee7 100644 --- a/apps/train/halkyon_train_server/Dockerfile +++ b/apps/train/halkyon_train_server/Dockerfile @@ -12,6 +12,9 @@ FROM dart:3.13.4 AS build WORKDIR /app COPY pubspec.yaml pubspec.lock ./ COPY tool/workspace_only.sh tool/ +# The root's dependency_overrides name these by path; pub refuses a path +# that isn't there, though no server uses them. +COPY third_party third_party COPY apps/train/halkyon_train_server apps/train/halkyon_train_server COPY packages/halkyon_base_server packages/halkyon_base_server COPY packages/halkyon_base_client packages/halkyon_base_client diff --git a/pubspec.lock b/pubspec.lock index 4d5c8752..7ef3992b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -807,20 +807,18 @@ packages: source: sdk version: "0.0.0" flutter_tts: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_tts - sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 - url: "https://pub.dev" - source: hosted + path: "third_party/flutter_tts" + relative: true + source: path version: "4.2.5" flutter_web_auth_2: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_web_auth_2 - sha256: a7655829251ee63aae64a748f8512f36670d8eba4657b0055cdf5ef304a9d164 - url: "https://pub.dev" - source: hosted + path: "third_party/flutter_web_auth_2" + relative: true + source: path version: "5.1.0" flutter_web_auth_2_platform_interface: dependency: transitive @@ -1142,12 +1140,11 @@ packages: source: hosted version: "4.12.0" keyboard_height_plugin: - dependency: transitive + dependency: "direct overridden" description: - name: keyboard_height_plugin - sha256: d57944cd3e4f8d9cb4adb1b7f1300d14a8dcec99bfb4414d78c7dd0120c1d66c - url: "https://pub.dev" - source: hosted + path: "third_party/keyboard_height_plugin" + relative: true + source: path version: "0.3.0" latlong2: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 19108565..982912fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,20 @@ dependency_overrides: # (`Logger.standard`, `Logger.verbose`, `Ansi`, `Progress`), the same in 0.5.2. # Drop this once flutter_launcher_icons allows 0.5. cli_util: ^0.5.2 + # Three Android plugins patched for Kotlin built into the Android Gradle + # plugin (android.builtInKotlin=true, which every app sets since 4 Nov + # 2026): their latest releases still apply the Kotlin Gradle plugin + # themselves, which Flutter warns will fail builds. Only android/build.gradle + # differs from the release (third_party/README.md); drop each override once + # its plugin's own release has migrated. flutter_web_auth_2 is + # halkyon_oidc_flutter's, keyboard_height_plugin appflowy_editor's (Learn's + # notes), flutter_tts Learn's voice mode's. + flutter_tts: + path: third_party/flutter_tts + flutter_web_auth_2: + path: third_party/flutter_web_auth_2 + keyboard_height_plugin: + path: third_party/keyboard_height_plugin # The offline sync package's shared part from our fork: v0.0.10 with seven # changes. Byte payloads compared as bytes: v0.0.10 compares them byte by byte # through a UUID parse that throws for each byte, so a sync touching a few thousand diff --git a/test/check_files_test.dart b/test/check_files_test.dart index bbeeb066..7b584125 100644 --- a/test/check_files_test.dart +++ b/test/check_files_test.dart @@ -43,6 +43,8 @@ void main() { 'apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations_de.dart', 'packages/halkyon_ui/lib/l10n/halkyon_ui_localizations_en.dart', 'packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart', + // Others' plugins, kept patched for their Gradle files alone. + 'third_party/flutter_tts/lib/flutter_tts.dart', ]) { expect(isOwnDartFile(path), isFalse, reason: path); } diff --git a/third_party/README.md b/third_party/README.md new file mode 100644 index 00000000..e8691bd5 --- /dev/null +++ b/third_party/README.md @@ -0,0 +1,23 @@ +# Others' plugins, patched + +Android plugins whose latest release still applies the Kotlin Gradle plugin itself, +which Flutter warns will fail builds once Kotlin is built into the Android Gradle +plugin (`android.builtInKotlin=true`, which every Halkyon app sets since 4 Nov 2026). +The root `pubspec.yaml` takes each from here through `dependency_overrides`. + +Each folder is the release from pub.dev as it was (its `example/`, `test/` and lint +options left out), under its own licence, but for `android/build.gradle`, changed as +[Flutter's guide for plugin authors](https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-plugin-authors) +has it: `apply plugin: 'kotlin-android'`, the Kotlin Gradle plugin's classpath, the +`kotlinOptions` block and the explicit `kotlin-stdlib` dropped, a `kotlin { +compilerOptions { jvmTarget } }` block added with the same JVM target. + +| Plugin | Release | Licence | Used by | +|---|---|---|---| +| `flutter_web_auth_2` | 5.1.0 | MIT | `halkyon_oidc_flutter` (signing in with SimpleLogin) | +| `keyboard_height_plugin` | 0.3.0 | BSD-3-Clause | `appflowy_editor` (Learn's notes) | +| `flutter_tts` | 4.2.5 | MIT | Learn's voice mode | + +When a plugin's own release has migrated, take it from pub.dev again: drop its +override and its folder here. Before then, a newer release is brought in by copying +it here and making the same change. diff --git a/third_party/flutter_tts/CHANGELOG.md b/third_party/flutter_tts/CHANGELOG.md new file mode 100644 index 00000000..869bc3da --- /dev/null +++ b/third_party/flutter_tts/CHANGELOG.md @@ -0,0 +1,493 @@ +# ChangeLog + +## 4.2.5 + +### Fixes + +- **Android:** Fixing namespace path + +## 4.2.4 + +### Fixes + +- **Windows:** CMake fix + +### Features + +- **iOS:** Add playbackAndRecord category to iOS audio category mapping +- **Android:** Bump to AGP 8.13 + +## 4.2.3 + +### Fixes + +- **Android:** Fix TTS reconnection behaviors +- **Windows:** Replace deprecated exec_program with execute_process +- **Windows:** Fix error on MSVC compilers + +## 4.2.2 + +### Fixes + +- **Web:** Fixing setLanguage on iOS browsers + +## 4.2.1 + +### Fixes + +- **iOS:** speeding up iOS plugin initialization +- **Android:** Fixes npe on plugin initialization + +## 4.2.0 + +### Features + +- **Windows:** Added extra metadata for voices +- **Web:** Added progress events using onboundary + +## 4.1.0 + +### Fixes + +- **Windows:** Fixes setvoices +- **iOS:** Fixes exported audio having the wrong sampleRate on ios 18 +- **Android:** Fixes 'Reply already submitted' bug during synthesizeToFile + +### Features + +- **Android:** Added AudioFocus and Audio Attributes For Navigation +- **Android/iOS/macOS:** Added extra metadata for voices +- **Web:** Migrated js_interop to support WASM +- **Android/iOS:** Improving synthesizeToFile + +## 4.0.2 + +### Fixes + +- **Package:** using recommended static analysis and fixing issues from analyze +- **Package:** Upating android example to use declarative plugins block + +## 4.0.1 + +### Fixes + +- **Android:** Fixing synthesizeToFile + +## 4.0.0 + +### Features + +- **iOS:** Add clear selected voice +- **Android:** Add clear selected voice +- **Android:** Upgrade to Kotlin 1.9.10 +- **Android:** Upgrade Gradle to 8.2.0 +- **Android:** Change compileSDK target to 34 + +### Fixes + +- **Android:** Fix TTS initialization (removed setInitHandler) +- **Web:** Fix pause +- **Package:** Fix describeEnum deprecation (requires Flutter SDK >=2.15.0) +- **Package:** Remove unnecessary foundation.dart include + +## 3.8.5 + +### Fixes + +- **iOS:** Fix AVSpeechSynthesisVoiceGender with available on iOS 13 or greater + +## 3.8.4 + +- **Android:** Fix 'Reply already submitted' bug +- **iOS:** Return additional voice information fields (quality, gender, and identifier) +- **iOS:** Fix synthesizeToFile on iOS 17+ + +## 3.8.3 + +### Fixes + +- **Android:** Clear pending method calls on initialization + +## 3.8.2 + +### Fixes + +- **Web:** Fixing onError to prevent crashing + +## 3.8.1 + +- **iOS:** Fixing fileName type back to URL + +## 3.8.0 + +### Features + +- *iOS/Android:** Support relative path of synthesization audio file +- **Android:** Improved android manifest notice +- **Android:** Removed deprecated registerWith +- **iOS:** Improved iOS audio documentation + +### Fixes + +- **Android:** Set speaking and synth to false in the stop method +- **Web:** Fix error in check available language without locale (country) + +## 3.7.0 + +### Features + +- **iOS:** add option to disable the auto stop for the audio session after speech completion +- **Android:** Upgrade gradle and kotlin. Make project compatible with AGP 8 + +### Fixes + +- **Web:** Add try-catch on initializing web plugin. +- **iOS:** Support project import fallback if the generated compatibility header is not copied when this plugin is created as a library. + +## 3.6.3 + +- **Android:** fixed initHandler with setEngineInitHandler +- **Android:** refactored pause work around which fixed a couple of reports bugs + +## 3.6.2 + +- **Android:** setEngine - adding initHandler within the method + +## 3.6.1 + +### Fixes + +- **Android:** setEngine initialization bug + +### Features + +- **Android:** added initHandler for android to help with engine initialization + +## 3.6.0 + +### Features + +- **Android:** Adding pause/continue +- **Windows:** Adding callbacks + +### Fixes + +- **All:** Adding async/await to Channel invokeMethods + +## 3.5.3 + +### Features + +- **Android:** Adding getDefaultVoice + +## 3.5.2 + +### Updates + +- **Android:** Converting Java to Kotlin and package upgrades + +## 3.5.1 + +### Fixes + +- **Web:** Fixing late variable and updating value being passed through a message channel to a primitive type + +## 3.5.0 + +### Features + +- **Web:** - Adding setVoice support + +### Fixes + +- **Web:** Fix chrome bug + +## 3.4.0 + +### Features + +- **Windows:** - Adding Windows Support + +## 3.3.3 + +### Fixes + +- **iOS:** Fix synthesizeToFile fatal error + +## 3.3.2 + +### Fixes + +- **Android:** Fix build.gradle missing property + +## 3.3.1 + +### Fixes + +- **Web:** Fix awaitSpeakCompletion + +## 3.3.0 + +### Features + +- **iOS:** Adding support for AVAudioSession.Mode + +## 3.2.4 + +### Fixes + +- **Android:** Fix compileSDK version + +## 3.2.3 + +### Fixes + +- **Android:** Fix await speak completion when queue mode is set to 1 + +## 3.2.2 + +### Fixes + +- **Android:** Android 11 fix + +## 3.2.1 + +### Fixes + +- **Android:** Fix deprecation warning + +## 3.2.0 + +### Features + +- **Android:** Adding get max speech input length +- **Android:** Changed Android speed rate mapping +- **iOS/macOS/Android:** Adding awaitSynthCompletion for synthesize to file + +### Fixes + +- **Android:** Fix tts connection disconnection after long running time + +## 3.1.0 + +### Features + +- **Android:** Adding set engine and get default engine + +## 3.0.0 + +### Features + +- Adding null safety + +## 2.1.0 + +### Features + +- **Android:** Adding the ability to set the queue mode + +## 2.0.0 + +### Features + +- **Android:** Adding installed language check + +### BREAKING CHANGES + +- **iOS/Android:** Adding voice name and locale to voice methods + +## 1.3.0 + +- All: Adding await speak completion + +## 1.2.7 + +- iOS: Adding utterance settings to synthesizeToFile +- Android: Fix crash when utterance completes after Engine is detached + +## 1.2.6 + +- Android: Fixing new API onAttachedToEngine call + +## 1.2.5 + +- macOS: Adding support + +## 1.2.4 + +- iOS: Deactivate audio session only when needed +- Android: Better exception handling for getLanguages +- Android: Adding getEngines +- Android: Adding new Android plugin APIs + +## 1.2.3 + +- iOS: Adding synthesize to file +- iOS: Fix set category ios + +## 1.2.2 + +- Web: Adding pause and continue + +## 1.2.1 + +- iOS: Fix setting iOS Category +- iOS/Android: Adding additional platform calls + +## 1.2.0 + +- iOS: Adding Set Audio Session Category +- Android: Bug fixes + +## 1.1.0 + +- iOS: Adding Pause +- iOS: Adding set Shared Instance + +## 1.0.0 + +- iOS/Android: Feature - Speech marks +- iOS/Android: Breaking changes to Speech rate + +## 0.9.2 + +- iOS: Changing audio session to playAndRecord + +## 0.9.1 + +- Android: Fixing method call error + +## 0.9.0 + +- Android: Adding synthesize to file + +## 0.8.7 + +- Android: Fix sporadic ANR on initialization + +## 0.8.6 + +- iOS: Reducing volume of other sessions while TTS is playing + +## 0.8.5 + +- Android: Fix Android async platform initialization +- Android: Removing initHandler + +## 0.8.2 + +- Web: Adding isLanguageAvailable method +- All: Fixing isLanguageAvailable platform channel and making it case insensitive + +## 0.8.0 + +- Web: Adding Web Support + +## 0.7.0 + +- iOS: Adding Swift version 4.2 to podspec and correct audio playback category + +## 0.6.0 + +- Android: AndroidX support + +## 0.5.2 + +- Android: Bug Fix on isLanguageAvailable + +## 0.5.1 + +- Applying flutter format to fix health suggestion + +## 0.5.0 + +- Android: Adding silence before speak +- Android: Removing deprecated API + +## 0.2.6 + +- IOS: Add voice selection implementation + +## 0.2.5 + +- Android: Ensure invokeMethod runs on main thread + +## 0.2.4 + +- Android: setting minSDK back to 21 and adding instructions to readme +- Android: Adding fallback for getLanguages and defaultLanguage + +## 0.2.3 + +- IOS: Audio continues with the Ring/Silent switch set to silent + +## 0.2.2 + +- Android: Fixing Locale bug + +## 0.2.1 + +- IOS: Fixing getLanguages bug + +## 0.2.0 + +- Android: Adding exception catch for samsung devices +- Android: Using default com.google.android.tts engine +- Android: Get and Set Voice +- Android: InitHandler +- Cleaning up the example + +## 0.1.2 + +- Support for Android background execution +- Updating Android build gradle version to 3.2.1 + +## 0.1.1 + +- Fixing TTS bound error in the example +- Fixing default voice language not found error on Android + +## 0.1.0 + +- Updating version for improved maintenance score +- Updating package description for improved maintenance score + +## 0.0.8 + +- Adding analysis_options.yaml for improved health score +- Fixing info/errors from flutter analyze + +## 0.0.7 + +- Updating SDK version in pubspec.yaml +- Adding package link to README +- Fixing language string warning received from xcode + +## 0.0.6 + +- Android: Upgrading Gradle 4.1 to 4.4 +- Android: Setting minSdk version to 21 +- Android: Adding try/catch to getAvailableLanguages & getDefaultVoice methods (Issues with API 21 & 22) + +## 0.0.5 + +- Adding IOS/Android isLanguageAvailable +- Rename setRate to setSpeechRate + +## 0.0.4 + +- Simplify podspec for Cocoapods 1.5.0 + +## 0.0.3 + +- Adding IOS/Android speech pitch and volume + +## 0.0.2 + +- Flutter formatting and fixing pubspec sdk versioning + +## 0.0.1 + +- first POC : + - methods : speak, stop, setRate, setLangauge, getLanguages + - a globalHandler for completion diff --git a/third_party/flutter_tts/LICENSE b/third_party/flutter_tts/LICENSE new file mode 100644 index 00000000..56dcdf22 --- /dev/null +++ b/third_party/flutter_tts/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Daniel Lutton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/flutter_tts/README.md b/third_party/flutter_tts/README.md new file mode 100644 index 00000000..6508a670 --- /dev/null +++ b/third_party/flutter_tts/README.md @@ -0,0 +1,275 @@ +# Text To Speech + +[![pub package](https://img.shields.io/pub/v/flutter_tts.svg?style=for-the-badge&colorB=green)](https://pub.dartlang.org/packages/flutter_tts) + +A flutter text to speech plugin (Swift,Kotlin) + +## Features + +- [x] Android, iOS, Web, Windows & macOS + - [x] speak + - [x] stop + - [x] get languages + - [x] set language + - [x] set speech rate + - [x] set speech volume + - [x] set speech pitch + - [x] get voices + - [x] set voice +- [x] Android, iOS, Web & macOS + - [x] is language available +- [x] Android, iOS, Web + - [x] speech marks (requires iOS 7+, Android 26+, and default voice engine for web) +- [x] Android, iOS + - [x] synthesize to file (requires iOS 13+) +- [x] Android, iOS, Web, & Windows + - [x] pause +- [x] Android + - [x] set silence + - [x] is language installed + - [x] are languages installed + - [x] get engines + - [x] set engine + - [x] get default engine + - [x] get default voice + - [x] set queue mode + - [x] get max speech input length +- [x] iOS + - [x] set shared instance + - [x] set audio session category + +## Usage + +## macOS + +```bash +OSX version: 10.15 +``` + +[Example App](https://github.com/dlutton/flutter_tts/tree/macOS_app) from the macOS_app branch + +## Web + +[Website](https://dlutton.github.io/flutter_tts) from the example directory. + +**Progress updates on Web** + +Progress updates are only supported for native speech synsthesis. Use the default engine to ensure support for progress updates. [Chromium#41195426](https://issues.chromium.org/issues/41195426#comment8) + +## Android + +Change the minimum Android sdk version to 21 (or higher) in your `android/app/build.gradle` file. + +```groovy +minSdkVersion 21 +``` + +**Update the Kotlin Gradle Plugin Version** + +Change the verision of the Kotlin Gradle plugin to `1.9.10`.
+If your project was created with a version of Flutter before 3.19, go to the `android/build.gradle` file and update the `ext.kotlin_version`: +```groovy +ext.kotlin_version = '1.9.10' +``` + +Otherwise go to `android/settings.gradle` and update the verion of the plugin `org.jetbrains.kotlin.android`: +```groovy +id "org.jetbrains.kotlin.android" version "1.9.10" apply false +``` + + +Apps targeting Android 11 that use text-to-speech should +declare [`TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE`](https://developer.android.com/reference/android/speech/tts/TextToSpeech.Engine#INTENT_ACTION_TTS_SERVICE) +in the `queries` elements of their manifest. + +```xml + + + + + +``` + +### Pausing on Android + +Android TTS does not support the pause function natively, so we have implemented a work around. We utilize the native `onRangeStart()` method to determine the index of start when `pause` is invoked. We use that index to create a new text the next time `speak` is invoked. Due to using `onRangeStart()`, pause works on SDK versions >= 26. Also, if using `start` and `end` offsets inside of `setProgressHandler()`, you'll need to keep a track of them if using `pause` since they will update once the new text is created when `speak` is called after being paused. + +```dart +await flutterTts.pause() +``` + +## iOS + +There's a known issue with integrating plugins that use Swift into a Flutter project created with the Objective-C template. [Flutter#16049](https://github.com/flutter/flutter/issues/16049) + +[Example](https://github.com/dlutton/flutter_tts/blob/master/example/lib/main.dart) + +To use this plugin : + +- add the dependency to your [pubspec.yaml](https://github.com/dlutton/flutter_tts/blob/master/example/pubspec.yaml) file. + +```yaml + dependencies: + flutter: + sdk: flutter + flutter_tts: +``` + +- instantiate FlutterTts + +```dart +FlutterTts flutterTts = FlutterTts(); + +``` + +To set shared audio [instance](https://developer.apple.com/documentation/avfoundation/avaudiosession/1616504-sharedinstance) (iOS only): + +```dart +await flutterTts.setSharedInstance(true); +``` + +To set audio [category and options](https://developer.apple.com/documentation/avfoundation/avaudiosession) with optional [mode](https://developer.apple.com/documentation/avfaudio/avaudiosession/mode) (iOS only). The following setup allows background music and in-app audio session to continue simultaneously: + +```dart +await flutterTts.setIosAudioCategory(IosTextToSpeechAudioCategory.ambient, + [ + IosTextToSpeechAudioCategoryOptions.allowBluetooth, + IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, + IosTextToSpeechAudioCategoryOptions.mixWithOthers + ], + IosTextToSpeechAudioMode.voicePrompt +); +``` + +To await speak completion. + +```dart +await flutterTts.awaitSpeakCompletion(true); +``` + +To await synthesize to file completion. + +```dart +await flutterTts.awaitSynthCompletion(true); +``` + +### speak, stop, getLanguages, setLanguage, setSpeechRate, getVoices, setVoice, setVolume, setPitch, isLanguageAvailable, setSharedInstance + +```dart +Future _speak() async{ + var result = await flutterTts.speak("Hello World"); + if (result == 1) setState(() => ttsState = TtsState.playing); +} + +Future _stop() async{ + var result = await flutterTts.stop(); + if (result == 1) setState(() => ttsState = TtsState.stopped); +} + +List languages = await flutterTts.getLanguages; + +await flutterTts.setLanguage("en-US"); + +await flutterTts.setSpeechRate(1.0); + +await flutterTts.setVolume(1.0); + +await flutterTts.setPitch(1.0); + +await flutterTts.isLanguageAvailable("en-US"); + +// iOS, Android and Web only +//see the "Pausing on Android" section for more info +await flutterTts.pause(); + +// iOS, macOS, and Android only +// The last parameter is an optional boolean value for isFullPath (defaults to false) +await flutterTts.synthesizeToFile("Hello World", Platform.isAndroid ? "tts.wav" : "tts.caf", false); + +// Each voice is a Map containing at least these keys: name, locale +// - Windows (UWP voices) only: gender, identifier +// - iOS, macOS only: quality, gender, identifier +// - Android only: quality, latency, network_required, features +List voices = await flutterTts.getVoices; + +await flutterTts.setVoice({"name": "Karen", "locale": "en-AU"}); +// iOS, macOS only +await flutterTts.setVoice({"identifier": "com.apple.voice.compact.en-AU.Karen"}); + +// iOS only +await flutterTts.setSharedInstance(true); + +// Android only +await flutterTts.speak("Hello World", focus: true); + +await flutterTts.setSilence(2); + +await flutterTts.getEngines; + +await flutterTts.getDefaultVoice; + +await flutterTts.isLanguageInstalled("en-AU"); + +await flutterTts.areLanguagesInstalled(["en-AU", "en-US"]); + +await flutterTts.setQueueMode(1); + +await flutterTts.getMaxSpeechInputLength; + +await flutterTts.setAudioAttributesForNavigation(); +``` + +### Listening for platform calls + +```dart +flutterTts.setStartHandler(() { + setState(() { + ttsState = TtsState.playing; + }); +}); + +flutterTts.setCompletionHandler(() { + setState(() { + ttsState = TtsState.stopped; + }); +}); + +flutterTts.setProgressHandler((String text, int startOffset, int endOffset, String word) { + setState(() { + _currentWord = word; + }); +}); + +flutterTts.setErrorHandler((msg) { + setState(() { + ttsState = TtsState.stopped; + }); +}); + +flutterTts.setCancelHandler((msg) { + setState(() { + ttsState = TtsState.stopped; + }); +}); + +// Android, iOS and Web +flutterTts.setPauseHandler((msg) { + setState(() { + ttsState = TtsState.paused; + }); +}); + +flutterTts.setContinueHandler((msg) { + setState(() { + ttsState = TtsState.continued; + }); +}); + +``` + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). + +For help on editing plugin code, view the [documentation](https://flutter.dev/platform-plugins/#edit-code). diff --git a/third_party/flutter_tts/android/build.gradle b/third_party/flutter_tts/android/build.gradle new file mode 100644 index 00000000..467e2975 --- /dev/null +++ b/third_party/flutter_tts/android/build.gradle @@ -0,0 +1,66 @@ +def args = ["-Xlint:deprecation"] + +group 'com.eyedeadevelopment.fluttertts' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '2.2.20' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +project.getTasks().withType(JavaCompile).configureEach { + options.compilerArgs.addAll(args) +} + +apply plugin: 'com.android.library' + +android { + compileSdk 36 + if (project.android.hasProperty("namespace")) { + namespace 'com.eyedeadevelopment.fluttertts' + } + + defaultConfig { + minSdkVersion 24 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lintOptions { + disable 'InvalidPackage' + disable 'GradleDependency' + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + +} +dependencies { +} +repositories { + mavenCentral() +} + +// Halkyon's patch (4 Nov 2026): Kotlin built into the Android Gradle plugin +// (AGP 9, android.builtInKotlin=true) rather than the Kotlin Gradle plugin +// applied here, as Flutter's guide for plugin authors has it. +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} diff --git a/third_party/flutter_tts/android/gradle.properties b/third_party/flutter_tts/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/third_party/flutter_tts/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/third_party/flutter_tts/android/gradle/wrapper/gradle-wrapper.properties b/third_party/flutter_tts/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..7ed55588 --- /dev/null +++ b/third_party/flutter_tts/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Sep 27 12:18:40 PDT 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip diff --git a/third_party/flutter_tts/android/settings.gradle b/third_party/flutter_tts/android/settings.gradle new file mode 100644 index 00000000..26aeb433 --- /dev/null +++ b/third_party/flutter_tts/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_tts' diff --git a/third_party/flutter_tts/android/src/main/AndroidManifest.xml b/third_party/flutter_tts/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0b70e4e0 --- /dev/null +++ b/third_party/flutter_tts/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + diff --git a/third_party/flutter_tts/android/src/main/kotlin/com/eyedeadevelopment/fluttertts/FlutterTtsPlugin.kt b/third_party/flutter_tts/android/src/main/kotlin/com/eyedeadevelopment/fluttertts/FlutterTtsPlugin.kt new file mode 100644 index 00000000..404341fe --- /dev/null +++ b/third_party/flutter_tts/android/src/main/kotlin/com/eyedeadevelopment/fluttertts/FlutterTtsPlugin.kt @@ -0,0 +1,815 @@ +package com.eyedeadevelopment.fluttertts + +import android.content.ContentValues +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.speech.tts.Voice +import io.flutter.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File +import java.lang.reflect.Field +import java.util.Locale +import java.util.MissingResourceException +import java.util.UUID + + +/** FlutterTtsPlugin */ +class FlutterTtsPlugin : MethodCallHandler, FlutterPlugin { + private var handler: Handler? = null + private var methodChannel: MethodChannel? = null + private var speakResult: Result? = null + private var synthResult: Result? = null + private var awaitSpeakCompletion = false + private var speaking = false + private var awaitSynthCompletion = false + private var synth = false + private var context: Context? = null + private var tts: TextToSpeech? = null + private val tag = "TTS" + private val pendingMethodCalls = ArrayList() + private val utterances = HashMap() + private var bundle: Bundle? = null + private var silencems = 0 + private var lastProgress = 0 + private var currentText: String? = null + private var pauseText: String? = null + private var isPaused: Boolean = false + private var queueMode: Int = TextToSpeech.QUEUE_FLUSH + private var ttsStatus: Int? = null + private var selectedEngine: String? = null + private var engineResult: Result? = null + private var parcelFileDescriptor: ParcelFileDescriptor? = null + private var audioManager: AudioManager? = null + private var audioFocusRequest: AudioFocusRequest? = null + + companion object { + private const val SILENCE_PREFIX = "SIL_" + private const val SYNTHESIZE_TO_FILE_PREFIX = "STF_" + } + + private fun initInstance(messenger: BinaryMessenger, context: Context) { + this.context = context + methodChannel = MethodChannel(messenger, "flutter_tts") + methodChannel!!.setMethodCallHandler(this) + handler = Handler(Looper.getMainLooper()) + bundle = Bundle() + tts = TextToSpeech(context, onInitListenerWithoutCallback) + } + + /** Android Plugin APIs */ + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + initInstance(binding.binaryMessenger, binding.applicationContext) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + stop() + tts!!.shutdown() + context = null + methodChannel!!.setMethodCallHandler(null) + methodChannel = null + } + + private val utteranceProgressListener: UtteranceProgressListener = + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + if (utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + invokeMethod("synth.onStart", true) + } else { + if (isPaused) { + invokeMethod("speak.onContinue", true) + isPaused = false + } else { + Log.d(tag, "Utterance ID has started: $utteranceId") + invokeMethod("speak.onStart", true) + } + } + if (Build.VERSION.SDK_INT < 26) { + onProgress(utteranceId, 0, utterances[utteranceId]!!.length) + } + } + + override fun onDone(utteranceId: String) { + if (utteranceId.startsWith(SILENCE_PREFIX)) return + if (utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + closeParcelFileDescriptor(false) + Log.d(tag, "Utterance ID has completed: $utteranceId") + if (awaitSynthCompletion) { + synthCompletion(1) + } + invokeMethod("synth.onComplete", true) + } else { + Log.d(tag, "Utterance ID has completed: $utteranceId") + if (awaitSpeakCompletion && queueMode == TextToSpeech.QUEUE_FLUSH) { + speakCompletion(1) + } + invokeMethod("speak.onComplete", true) + } + lastProgress = 0 + pauseText = null + utterances.remove(utteranceId) + releaseAudioFocus() + } + + override fun onStop(utteranceId: String, interrupted: Boolean) { + Log.d( + tag, + "Utterance ID has been stopped: $utteranceId. Interrupted: $interrupted" + ) + if (awaitSpeakCompletion) { + speaking = false + } + if (isPaused) { + invokeMethod("speak.onPause", true) + } else { + invokeMethod("speak.onCancel", true) + } + releaseAudioFocus() + } + + private fun onProgress(utteranceId: String?, startAt: Int, endAt: Int) { + if (utteranceId != null && !utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + val text = utterances[utteranceId] + val data = HashMap() + data["text"] = text + data["start"] = startAt.toString() + data["end"] = endAt.toString() + data["word"] = text!!.substring(startAt, endAt) + invokeMethod("speak.onProgress", data) + } + } + + // Requires Android 26 or later + override fun onRangeStart(utteranceId: String, startAt: Int, endAt: Int, frame: Int) { + if (!utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + lastProgress = startAt + super.onRangeStart(utteranceId, startAt, endAt, frame) + onProgress(utteranceId, startAt, endAt) + } + } + + @Deprecated("") + override fun onError(utteranceId: String) { + if (utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + closeParcelFileDescriptor(true) + if (awaitSynthCompletion) { + synth = false + } + invokeMethod("synth.onError", "Error from TextToSpeech (synth)") + } else { + if (awaitSpeakCompletion) { + speaking = false + } + invokeMethod("speak.onError", "Error from TextToSpeech (speak)") + } + releaseAudioFocus() + } + + override fun onError(utteranceId: String, errorCode: Int) { + if (utteranceId.startsWith(SYNTHESIZE_TO_FILE_PREFIX)) { + closeParcelFileDescriptor(true) + if (awaitSynthCompletion) { + synth = false + } + invokeMethod("synth.onError", "Error from TextToSpeech (synth) - $errorCode") + } else { + if (awaitSpeakCompletion) { + speaking = false + } + invokeMethod("speak.onError", "Error from TextToSpeech (speak) - $errorCode") + } + } + } + + fun speakCompletion(success: Int) { + speaking = false + handler!!.post { + speakResult?.success(success) + speakResult = null + } + } + + fun synthCompletion(success: Int) { + synth = false + handler!!.post { + synthResult?.success(success) + synthResult = null + } + } + + private val onInitListenerWithCallback: TextToSpeech.OnInitListener = + TextToSpeech.OnInitListener { status -> + // Handle pending method calls (sent while TTS was initializing) + synchronized(this@FlutterTtsPlugin) { + ttsStatus = status + for (call in pendingMethodCalls) { + call.run() + } + pendingMethodCalls.clear() + } + + if (status == TextToSpeech.SUCCESS) { + tts!!.setOnUtteranceProgressListener(utteranceProgressListener) + try { + val locale: Locale = tts!!.defaultVoice.locale + if (isLanguageAvailable(locale)) { + tts!!.language = locale + } + } catch (e: NullPointerException) { + Log.e(tag, "getDefaultLocale: " + e.message) + } catch (e: IllegalArgumentException) { + Log.e(tag, "getDefaultLocale: " + e.message) + } + + engineResult!!.success(1) + } else { + engineResult!!.error("TtsError","Failed to initialize TextToSpeech with status: $status", null) + } + //engineResult = null + } + + private val onInitListenerWithoutCallback: TextToSpeech.OnInitListener = + TextToSpeech.OnInitListener { status -> + // Handle pending method calls (sent while TTS was initializing) + synchronized(this@FlutterTtsPlugin) { + ttsStatus = status + for (call in pendingMethodCalls) { + call.run() + } + pendingMethodCalls.clear() + } + + if (status == TextToSpeech.SUCCESS) { + tts!!.setOnUtteranceProgressListener(utteranceProgressListener) + try { + val locale: Locale = tts!!.defaultVoice.locale + if (isLanguageAvailable(locale)) { + tts!!.language = locale + } + } catch (e: NullPointerException) { + Log.e(tag, "getDefaultLocale: " + e.message) + } catch (e: IllegalArgumentException) { + Log.e(tag, "getDefaultLocale: " + e.message) + } + } else { + Log.e(tag, "Failed to initialize TextToSpeech with status: $status") + } + } + + override fun onMethodCall(call: MethodCall, result: Result) { + // If TTS is still loading + synchronized(this@FlutterTtsPlugin) { + if (ttsStatus == null) { + // Suspend method call until the TTS engine is ready + val suspendedCall = Runnable { onMethodCall(call, result) } + pendingMethodCalls.add(suspendedCall) + return + } + } + when (call.method) { + "speak" -> { + var text: String = call.argument("text")!! + val focus: Boolean = call.argument("focus")!! + if (pauseText == null) { + pauseText = text + currentText = pauseText!! + } + if (isPaused) { + // Ensure the text hasn't changed + if (currentText == text) { + text = pauseText!! + } else { + pauseText = text + currentText = pauseText!! + lastProgress = 0 + } + } + if (speaking) { + // If TTS is set to queue mode, allow the utterance to be queued up rather than discarded + if (queueMode == TextToSpeech.QUEUE_FLUSH) { + result.success(0) + return + } + } + val b = speak(text, focus) + if (!b) { + synchronized(this@FlutterTtsPlugin) { + val suspendedCall = Runnable { onMethodCall(call, result) } + pendingMethodCalls.add(suspendedCall) + } + return + } + // Only use await speak completion if queueMode is set to QUEUE_FLUSH + if (awaitSpeakCompletion && queueMode == TextToSpeech.QUEUE_FLUSH) { + speaking = true + speakResult = result + } else { + result.success(1) + } + } + + "awaitSpeakCompletion" -> { + awaitSpeakCompletion = java.lang.Boolean.parseBoolean(call.arguments.toString()) + result.success(1) + } + + "awaitSynthCompletion" -> { + awaitSynthCompletion = java.lang.Boolean.parseBoolean(call.arguments.toString()) + result.success(1) + } + + "getMaxSpeechInputLength" -> { + val res = maxSpeechInputLength + result.success(res) + } + + "synthesizeToFile" -> { + val text: String? = call.argument("text") + if (synth) { + result.success(0) + return + } + val fileName: String? = call.argument("fileName") + val isFullPath: Boolean? = call.argument("isFullPath") + synthesizeToFile(text!!, fileName!!, isFullPath!!) + if (awaitSynthCompletion) { + synth = true + synthResult = result + } else { + result.success(1) + } + } + + "pause" -> { + isPaused = true + if (pauseText != null) { + pauseText = pauseText!!.substring(lastProgress) + } + stop() + result.success(1) + if (speakResult != null) { + speakResult!!.success(0) + speakResult = null + } + } + + "stop" -> { + isPaused = false + pauseText = null + stop() + lastProgress = 0 + result.success(1) + if (speakResult != null) { + speakResult!!.success(0) + speakResult = null + } + } + + "setEngine" -> { + val engine: String = call.arguments.toString() + setEngine(engine, result) + } + + "setSpeechRate" -> { + val rate: String = call.arguments.toString() + // To make the FlutterTts API consistent across platforms, + // Android 1.0 is mapped to flutter 0.5. + setSpeechRate(rate.toFloat() * 2.0f) + result.success(1) + } + + "setVolume" -> { + val volume: String = call.arguments.toString() + setVolume(volume.toFloat(), result) + } + + "setPitch" -> { + val pitch: String = call.arguments.toString() + setPitch(pitch.toFloat(), result) + } + + "setLanguage" -> { + val language: String = call.arguments.toString() + setLanguage(language, result) + } + + "getLanguages" -> getLanguages(result) + "getVoices" -> getVoices(result) + "getSpeechRateValidRange" -> getSpeechRateValidRange(result) + "getEngines" -> getEngines(result) + "getDefaultEngine" -> getDefaultEngine(result) + "getDefaultVoice" -> getDefaultVoice(result) + "setVoice" -> { + val voice: HashMap? = call.arguments() + setVoice(voice!!, result) + } + + "clearVoice" -> clearVoice(result) + + "isLanguageAvailable" -> { + val language: String = call.arguments.toString() + val locale: Locale = Locale.forLanguageTag(language) + result.success(isLanguageAvailable(locale)) + } + + "setSilence" -> { + val silencems: String = call.arguments.toString() + this.silencems = silencems.toInt() + } + + "setSharedInstance" -> result.success(1) + "isLanguageInstalled" -> { + val language: String = call.arguments.toString() + result.success(isLanguageInstalled(language)) + } + + "areLanguagesInstalled" -> { + val languages: List? = call.arguments() + result.success(areLanguagesInstalled(languages!!)) + } + + "setQueueMode" -> { + val queueMode: String = call.arguments.toString() + this.queueMode = queueMode.toInt() + result.success(1) + } + + "setAudioAttributesForNavigation" -> { + setAudioAttributesForNavigation() + result.success(1) + } + + else -> result.notImplemented() + } + } + + private fun setSpeechRate(rate: Float) { + tts!!.setSpeechRate(rate) + } + + private fun isLanguageAvailable(locale: Locale?): Boolean { + return tts!!.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE + } + + private fun areLanguagesInstalled(languages: List): Map { + val result: MutableMap = HashMap() + for (language in languages) { + result[language] = isLanguageInstalled(language) + } + return result + } + + private fun isLanguageInstalled(language: String?): Boolean { + val locale: Locale = Locale.forLanguageTag(language!!) + if (isLanguageAvailable(locale)) { + var voiceToCheck: Voice? = null + for (v in tts!!.voices) { + if (v.locale == locale && !v.isNetworkConnectionRequired) { + voiceToCheck = v + break + } + } + if (voiceToCheck != null) { + val features: Set = voiceToCheck.features + return (!features.contains(TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED)) + } + } + return false + } + + private fun setEngine(engine: String?, result: Result) { + ttsStatus = null + selectedEngine = engine + engineResult = result + tts = TextToSpeech(context, onInitListenerWithCallback, engine) + } + + private fun setLanguage(language: String?, result: Result) { + val locale: Locale = Locale.forLanguageTag(language!!) + if (isLanguageAvailable(locale)) { + tts!!.language = locale + result.success(1) + } else { + result.success(0) + } + } + + private fun setVoice(voice: HashMap, result: Result) { + for (ttsVoice in tts!!.voices) { + if (ttsVoice.name == voice["name"] && ttsVoice.locale + .toLanguageTag() == voice["locale"] + ) { + tts!!.voice = ttsVoice + result.success(1) + return + } + } + Log.d(tag, "Voice name not found: $voice") + result.success(0) + } + + private fun clearVoice(result: Result) { + tts!!.voice = tts!!.defaultVoice + result.success(1) + } + + private fun setVolume(volume: Float, result: Result) { + if (volume in (0.0f..1.0f)) { + bundle!!.putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume) + result.success(1) + } else { + Log.d(tag, "Invalid volume $volume value - Range is from 0.0 to 1.0") + result.success(0) + } + } + + private fun setPitch(pitch: Float, result: Result) { + if (pitch in (0.5f..2.0f)) { + tts!!.setPitch(pitch) + result.success(1) + } else { + Log.d(tag, "Invalid pitch $pitch value - Range is from 0.5 to 2.0") + result.success(0) + } + } + + private fun getVoices(result: Result) { + val voices = ArrayList>() + try { + for (voice in tts!!.voices) { + val voiceMap = HashMap() + readVoiceProperties(voiceMap, voice) + voices.add(voiceMap) + } + result.success(voices) + } catch (e: NullPointerException) { + Log.d(tag, "getVoices: " + e.message) + result.success(null) + } + } + + private fun getLanguages(result: Result) { + val locales = ArrayList() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // While this method was introduced in API level 21, it seems that it + // has not been implemented in the speech service side until API Level 23. + for (locale in tts!!.availableLanguages) { + locales.add(locale.toLanguageTag()) + } + } else { + for (locale in Locale.getAvailableLocales()) { + if (locale.variant.isEmpty() && isLanguageAvailable(locale)) { + locales.add(locale.toLanguageTag()) + } + } + } + } catch (e: MissingResourceException) { + Log.d(tag, "getLanguages: " + e.message) + } catch (e: NullPointerException) { + Log.d(tag, "getLanguages: " + e.message) + } + result.success(locales) + } + + private fun getEngines(result: Result) { + val engines = ArrayList() + try { + for (engineInfo in tts!!.engines) { + engines.add(engineInfo.name) + } + } catch (e: Exception) { + Log.d(tag, "getEngines: " + e.message) + } + result.success(engines) + } + + private fun getDefaultEngine(result: Result) { + val defaultEngine: String? = tts!!.defaultEngine + result.success(defaultEngine) + } + + private fun getDefaultVoice(result: Result) { + val defaultVoice: Voice? = tts!!.defaultVoice + val voice = HashMap() + if (defaultVoice != null) { + readVoiceProperties(voice, defaultVoice) + } + result.success(voice) + } + // Add voice properties into the voice map + fun readVoiceProperties(map: MutableMap, voice: Voice) { + map["name"] = voice.name + map["locale"] = voice.locale.toLanguageTag() + map["quality"] = qualityToString(voice.quality) + map["latency"] = latencyToString(voice.latency) + map["network_required"] = if (voice.isNetworkConnectionRequired) "1" else "0" + map["features"] = voice.features.joinToString(separator = "\t") + + } + + // Function to map quality integer to the constant name + fun qualityToString(quality: Int): String { + return when (quality) { + Voice.QUALITY_VERY_HIGH -> "very high" + Voice.QUALITY_HIGH -> "high" + Voice.QUALITY_NORMAL -> "normal" + Voice.QUALITY_LOW -> "low" + Voice.QUALITY_VERY_LOW -> "very low" + else -> "unknown" + } + } + // Function to map latency integer to the constant name + fun latencyToString(quality: Int): String { + return when (quality) { + Voice.LATENCY_VERY_HIGH -> "very high" + Voice.LATENCY_HIGH -> "high" + Voice.LATENCY_NORMAL -> "normal" + Voice.LATENCY_LOW -> "low" + Voice.LATENCY_VERY_LOW -> "very low" + else -> "unknown" + } + } + + private fun getSpeechRateValidRange(result: Result) { + // Valid values available in the android documentation. + // https://developer.android.com/reference/android/speech/tts/TextToSpeech#setSpeechRate(float) + // To make the FlutterTts API consistent across platforms, + // we map Android 1.0 to flutter 0.5 and so on. + val data = HashMap() + data["min"] = "0" + data["normal"] = "0.5" + data["max"] = "1.5" + data["platform"] = "android" + result.success(data) + } + + private fun speak(text: String, focus: Boolean): Boolean { + val uuid: String = UUID.randomUUID().toString() + utterances[uuid] = text + return if (ismServiceConnectionUsable(tts)) { + if(focus){ + requestAudioFocus() + } + + if (silencems > 0) { + tts!!.playSilentUtterance( + silencems.toLong(), + TextToSpeech.QUEUE_FLUSH, + SILENCE_PREFIX + uuid + ) + tts!!.speak(text, TextToSpeech.QUEUE_ADD, bundle, uuid) == 0 + } else { + tts!!.speak(text, queueMode, bundle, uuid) == 0 + } + } else { + ttsStatus = null + tts = TextToSpeech(context, onInitListenerWithoutCallback, selectedEngine) + false + } + } + + private fun stop() { + if (awaitSynthCompletion) synth = false + if (awaitSpeakCompletion) speaking = false + tts!!.stop() + } + + private val maxSpeechInputLength: Int + get() = TextToSpeech.getMaxSpeechInputLength() + + private fun closeParcelFileDescriptor(isError: Boolean) { + if (this.parcelFileDescriptor != null) { + if (isError) { + this.parcelFileDescriptor!!.closeWithError("Error synthesizing TTS to file") + } else { + this.parcelFileDescriptor!!.close() + } + } + } + + private fun synthesizeToFile(text: String, fileName: String, isFullPath: Boolean) { + val fullPath: String + val uuid: String = UUID.randomUUID().toString() + bundle!!.putString( + TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, + SYNTHESIZE_TO_FILE_PREFIX + uuid + ) + + val result: Int = + if(isFullPath){ + val file = File(fileName) + fullPath = file.path + + tts!!.synthesizeToFile(text, bundle!!, file!!, SYNTHESIZE_TO_FILE_PREFIX + uuid) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val resolver = this.context?.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_MUSIC) + } + val uri = resolver?.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, contentValues) + this.parcelFileDescriptor = resolver?.openFileDescriptor(uri!!, "rw") + fullPath = uri?.path + File.separatorChar + fileName + + tts!!.synthesizeToFile(text, bundle!!, parcelFileDescriptor!!, SYNTHESIZE_TO_FILE_PREFIX + uuid) + } else { + val musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC) + val file = File(musicDir, fileName) + fullPath = file.path + + tts!!.synthesizeToFile(text, bundle!!, file!!, SYNTHESIZE_TO_FILE_PREFIX + uuid) + } + + if (result == TextToSpeech.SUCCESS) { + Log.d(tag, "Successfully created file : $fullPath") + } else { + Log.d(tag, "Failed creating file : $fullPath") + } + } + + private fun invokeMethod(method: String, arguments: Any) { + handler!!.post { + if (methodChannel != null) methodChannel!!.invokeMethod( + method, + arguments + ) + } + } + + private fun ismServiceConnectionUsable(tts: TextToSpeech?): Boolean { + var isBindConnection = true + if (tts == null) { + return false + } + val fields: Array = tts.javaClass.declaredFields + for (j in fields.indices) { + fields[j].isAccessible = true + if ("mServiceConnection" == fields[j].name && "android.speech.tts.TextToSpeech\$Connection" == fields[j].type.name) { + try { + if (fields[j][tts] == null) { + isBindConnection = false + Log.e(tag, "*******TTS -> mServiceConnection == null*******") + } + } catch (e: IllegalArgumentException) { + e.printStackTrace() + } catch (e: IllegalAccessException) { + e.printStackTrace() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return isBindConnection + } + + // Method to set AudioAttributes for navigation usage + private fun setAudioAttributesForNavigation() { + if (tts != null) { + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + tts!!.setAudioAttributes(audioAttributes) + } + } + + private fun requestAudioFocus() { + audioManager = context?.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setOnAudioFocusChangeListener { /* opcional para monitorar mudanças de foco */ } + .build() + audioManager?.requestAudioFocus(audioFocusRequest!!) + } else { + audioManager?.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + } + } + + private fun releaseAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager?.abandonAudioFocusRequest(it) } + } else { + audioManager?.abandonAudioFocus(null) + } + } +} diff --git a/third_party/flutter_tts/ios/Classes/AudioCategory.swift b/third_party/flutter_tts/ios/Classes/AudioCategory.swift new file mode 100644 index 00000000..74e8ea0b --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/AudioCategory.swift @@ -0,0 +1,21 @@ +import AVFoundation + +enum AudioCategory: String { + case iosAudioCategoryAmbientSolo + case iosAudioCategoryAmbient + case iosAudioCategoryPlayback + case iosAudioCategoryPlaybackAndRecord + + func toAVAudioSessionCategory() -> AVAudioSession.Category { + switch self { + case .iosAudioCategoryAmbientSolo: + return .soloAmbient + case .iosAudioCategoryAmbient: + return .ambient + case .iosAudioCategoryPlayback: + return .playback + case .iosAudioCategoryPlaybackAndRecord: + return .playAndRecord + } + } +} diff --git a/third_party/flutter_tts/ios/Classes/AudioCategoryOptions.swift b/third_party/flutter_tts/ios/Classes/AudioCategoryOptions.swift new file mode 100644 index 00000000..f593bfdb --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/AudioCategoryOptions.swift @@ -0,0 +1,42 @@ +import AVFoundation + +enum AudioCategoryOptions: String { + case iosAudioCategoryOptionsMixWithOthers + case iosAudioCategoryOptionsDuckOthers + case iosAudioCategoryOptionsInterruptSpokenAudioAndMixWithOthers + case iosAudioCategoryOptionsAllowBluetooth + case iosAudioCategoryOptionsAllowBluetoothA2DP + case iosAudioCategoryOptionsAllowAirPlay + case iosAudioCategoryOptionsDefaultToSpeaker + + func toAVAudioSessionCategoryOptions() -> AVAudioSession.CategoryOptions? { + switch self { + case .iosAudioCategoryOptionsMixWithOthers: + return .mixWithOthers + case .iosAudioCategoryOptionsDuckOthers: + return .duckOthers + case .iosAudioCategoryOptionsInterruptSpokenAudioAndMixWithOthers: + if #available(iOS 9.0, *) { + return .interruptSpokenAudioAndMixWithOthers + } else { + return nil + } + case .iosAudioCategoryOptionsAllowBluetooth: + return .allowBluetooth + case .iosAudioCategoryOptionsAllowBluetoothA2DP: + if #available(iOS 10.0, *) { + return .allowBluetoothA2DP + } else { + return nil + } + case .iosAudioCategoryOptionsAllowAirPlay: + if #available(iOS 10.0, *) { + return .allowAirPlay + } else { + return nil + } + case .iosAudioCategoryOptionsDefaultToSpeaker: + return .defaultToSpeaker + } + } +} diff --git a/third_party/flutter_tts/ios/Classes/AudioModes.swift b/third_party/flutter_tts/ios/Classes/AudioModes.swift new file mode 100644 index 00000000..e2e30716 --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/AudioModes.swift @@ -0,0 +1,63 @@ +import AVFoundation + +enum AudioModes: String { + case iosAudioModeDefault + case iosAudioModeGameChat + case iosAudioModeMeasurement + case iosAudioModeMoviePlayback + case iosAudioModeSpokenAudio + case iosAudioModeVideoChat + case iosAudioModeVideoRecording + case iosAudioModeVoiceChat + case iosAudioModeVoicePrompt + + func toAVAudioSessionMode() -> AVAudioSession.Mode? { + switch self { + case .iosAudioModeDefault: + if #available(iOS 12.0, *) { + return .default + } + return nil + case .iosAudioModeGameChat: + if #available(iOS 12.0, *) { + return .gameChat + } + return nil + case .iosAudioModeMeasurement: + if #available(iOS 12.0, *) { + return .measurement + } + return nil + case .iosAudioModeMoviePlayback: + if #available(iOS 12.0, *) { + return .moviePlayback + } + return nil + case .iosAudioModeSpokenAudio: + if #available(iOS 12.0, *) { + return .spokenAudio + } + return nil + case .iosAudioModeVideoChat: + if #available(iOS 12.0, *) { + return .videoChat + } + return nil + case .iosAudioModeVideoRecording: + if #available(iOS 12.0, *) { + return .videoRecording + } + return nil + case .iosAudioModeVoiceChat: + if #available(iOS 12.0, *) { + return .voiceChat + } + return nil + case .iosAudioModeVoicePrompt: + if #available(iOS 12.0, *) { + return .voicePrompt + } + return nil + } + } +} diff --git a/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.h b/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.h new file mode 100644 index 00000000..13e3a21a --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface FlutterTtsPlugin : NSObject +@end diff --git a/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.m b/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.m new file mode 100644 index 00000000..3fc014ff --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/FlutterTtsPlugin.m @@ -0,0 +1,15 @@ +#import "FlutterTtsPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "flutter_tts-Swift.h" +#endif + +@implementation FlutterTtsPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftFlutterTtsPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/third_party/flutter_tts/ios/Classes/SwiftFlutterTtsPlugin.swift b/third_party/flutter_tts/ios/Classes/SwiftFlutterTtsPlugin.swift new file mode 100644 index 00000000..7b18a353 --- /dev/null +++ b/third_party/flutter_tts/ios/Classes/SwiftFlutterTtsPlugin.swift @@ -0,0 +1,506 @@ +import Flutter +import UIKit +import AVFoundation + +public class SwiftFlutterTtsPlugin: NSObject, FlutterPlugin, AVSpeechSynthesizerDelegate { + let iosAudioCategoryKey = "iosAudioCategoryKey" + let iosAudioCategoryOptionsKey = "iosAudioCategoryOptionsKey" + let iosAudioModeKey = "iosAudioModeKey" + + let synthesizer = AVSpeechSynthesizer() + var rate: Float = AVSpeechUtteranceDefaultSpeechRate + var volume: Float = 1.0 + var pitch: Float = 1.0 + var voice: AVSpeechSynthesisVoice? + var awaitSpeakCompletion: Bool = false + var awaitSynthCompletion: Bool = false + var autoStopSharedSession: Bool = true + var speakResult: FlutterResult? = nil + var synthResult: FlutterResult? = nil + + lazy var audioSession = AVAudioSession.sharedInstance() + lazy var language: String = { + AVSpeechSynthesisVoice.currentLanguageCode() + }() + lazy var languages: Set = { + Set(AVSpeechSynthesisVoice.speechVoices().map(\.language)) + }() + + + var channel = FlutterMethodChannel() + init(channel: FlutterMethodChannel) { + super.init() + self.channel = channel + synthesizer.delegate = self + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_tts", binaryMessenger: registrar.messenger()) + let instance = SwiftFlutterTtsPlugin(channel: channel) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "speak": + let text: String = call.arguments as! String + self.speak(text: text, result: result) + break + case "awaitSpeakCompletion": + self.awaitSpeakCompletion = call.arguments as! Bool + result(1) + break + case "awaitSynthCompletion": + self.awaitSynthCompletion = call.arguments as! Bool + result(1) + break + case "synthesizeToFile": + guard let args = call.arguments as? [String: Any] else { + result("iOS could not recognize flutter arguments in method: (sendParams)") + return + } + let text = args["text"] as! String + let fileName = args["fileName"] as! String + let isFullPath = args["isFullPath"] as! Bool + self.synthesizeToFile(text: text, fileName: fileName, isFullPath: isFullPath, result: result) + break + case "pause": + self.pause(result: result) + break + case "setLanguage": + let language: String = call.arguments as! String + self.setLanguage(language: language, result: result) + break + case "setSpeechRate": + let rate: Double = call.arguments as! Double + self.setRate(rate: Float(rate)) + result(1) + break + case "setVolume": + let volume: Double = call.arguments as! Double + self.setVolume(volume: Float(volume), result: result) + break + case "setPitch": + let pitch: Double = call.arguments as! Double + self.setPitch(pitch: Float(pitch), result: result) + break + case "stop": + self.stop() + result(1) + break + case "getLanguages": + self.getLanguages(result: result) + break + case "getSpeechRateValidRange": + self.getSpeechRateValidRange(result: result) + break + case "isLanguageAvailable": + let language: String = call.arguments as! String + self.isLanguageAvailable(language: language, result: result) + break + case "getVoices": + self.getVoices(result: result) + break + case "setVoice": + guard let args = call.arguments as? [String: String] else { + result("iOS could not recognize flutter arguments in method: (sendParams)") + return + } + self.setVoice(voice: args, result: result) + break + case "clearVoice": + self.clearVoice() + result(1) + break + case "setSharedInstance": + let sharedInstance = call.arguments as! Bool + self.setSharedInstance(sharedInstance: sharedInstance, result: result) + break + case "autoStopSharedSession": + let autoStop = call.arguments as! Bool + self.autoStopSharedSession = autoStop + result(1) + break + case "setIosAudioCategory": + guard let args = call.arguments as? [String: Any] else { + result("iOS could not recognize flutter arguments in method: (sendParams)") + return + } + let audioCategory = args["iosAudioCategoryKey"] as? String + let audioOptions = args[iosAudioCategoryOptionsKey] as? Array + let audioModes = args[iosAudioModeKey] as? String + self.setAudioCategory(audioCategory: audioCategory, audioOptions: audioOptions, audioMode: audioModes, result: result) + break + default: + result(FlutterMethodNotImplemented) + } + } + + private func speak(text: String, result: @escaping FlutterResult) { + if (self.synthesizer.isPaused) { + if (self.synthesizer.continueSpeaking()) { + if self.awaitSpeakCompletion { + self.speakResult = result + } else { + result(1) + } + } else { + result(0) + } + } else { + let utterance = AVSpeechUtterance(string: text) + if self.voice != nil { + utterance.voice = self.voice! + } else { + utterance.voice = AVSpeechSynthesisVoice(language: self.language) + } + utterance.rate = self.rate + utterance.volume = self.volume + utterance.pitchMultiplier = self.pitch + + self.synthesizer.speak(utterance) + if self.awaitSpeakCompletion { + self.speakResult = result + } else { + result(1) + } + } + } + + private func synthesizeToFile(text: String, fileName: String, isFullPath: Bool, result: @escaping FlutterResult) { + var output: AVAudioFile? + var failed = false + let utterance = AVSpeechUtterance(string: text) + + if self.voice != nil { + utterance.voice = self.voice! + } else { + utterance.voice = AVSpeechSynthesisVoice(language: self.language) + } + utterance.rate = self.rate + utterance.volume = self.volume + utterance.pitchMultiplier = self.pitch + + if #available(iOS 13.0, *) { + self.synthesizer.write(utterance) { (buffer: AVAudioBuffer) in + guard let pcmBuffer = buffer as? AVAudioPCMBuffer else { + NSLog("unknow buffer type: \(buffer)") + failed = true + return + } + print(pcmBuffer.format) + if pcmBuffer.frameLength == 0 { + // finished + } else { + // append buffer to file + let fileURL: URL + if isFullPath { + fileURL = URL(fileURLWithPath: fileName) + } else { + fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + } + NSLog("Saving utterance to file: \(fileURL.absoluteString)") + + if output == nil { + do { + if #available(iOS 17.0, *) { + guard let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: pcmBuffer.format.sampleRate, channels: 1, interleaved: false) else { + NSLog("Error creating audio format for iOS 17+") + failed = true + return + } + output = try AVAudioFile(forWriting: fileURL, settings: audioFormat.settings) + } else { + output = try AVAudioFile(forWriting: fileURL, settings: pcmBuffer.format.settings, commonFormat: .pcmFormatFloat32, interleaved: false) + } + } catch { + NSLog("Error creating AVAudioFile: \(error.localizedDescription)") + failed = true + return + } + } + + + try! output!.write(from: pcmBuffer) + } + } + } else { + result("Unsupported iOS version") + } + if failed { + result(0) + } + if self.awaitSynthCompletion { + self.synthResult = result + } else { + result(1) + } + } + + private func pause(result: FlutterResult) { + if (self.synthesizer.pauseSpeaking(at: AVSpeechBoundary.word)) { + result(1) + } else { + result(0) + } + } + + private func setLanguage(language: String, result: FlutterResult) { + if !(self.languages.contains(where: {$0.range(of: language, options: [.caseInsensitive, .anchored]) != nil})) { + result(0) + } else { + self.language = language + self.voice = nil + result(1) + } + } + + private func setRate(rate: Float) { + self.rate = rate + } + + private func setVolume(volume: Float, result: FlutterResult) { + if (volume >= 0.0 && volume <= 1.0) { + self.volume = volume + result(1) + } else { + result(0) + } + } + + private func setPitch(pitch: Float, result: FlutterResult) { + if (volume >= 0.5 && volume <= 2.0) { + self.pitch = pitch + result(1) + } else { + result(0) + } + } + + private func setSharedInstance(sharedInstance: Bool, result: FlutterResult) { + do { + try AVAudioSession.sharedInstance().setActive(sharedInstance) + result(1) + } catch { + result(0) + } + } + + private func setAudioCategory(audioCategory: String?, audioOptions: Array?, audioMode: String?, result: FlutterResult){ + let category: AVAudioSession.Category = AudioCategory(rawValue: audioCategory ?? "")?.toAVAudioSessionCategory() ?? audioSession.category + let options: AVAudioSession.CategoryOptions = audioOptions?.reduce([], { (result, option) -> AVAudioSession.CategoryOptions in + return result.union(AudioCategoryOptions(rawValue: option)?.toAVAudioSessionCategoryOptions() ?? [])}) ?? [] + do { + if #available(iOS 12.0, *) { + if audioMode == nil { + try audioSession.setCategory(category, options: options) + } else { + let mode: AVAudioSession.Mode? = AudioModes(rawValue: audioMode ?? "")?.toAVAudioSessionMode() ?? AVAudioSession.Mode.default + try audioSession.setCategory(category, mode: mode!, options: options) + } + } else { + try audioSession.setCategory(category, options: options) + } + result(1) + } catch { + print(error) + result(0) + } + } + + private func stop() { + self.synthesizer.stopSpeaking(at: AVSpeechBoundary.immediate) + } + + private func getLanguages(result: FlutterResult) { + result(Array(self.languages)) + } + + private func getSpeechRateValidRange(result: FlutterResult) { + let validSpeechRateRange: [String:String] = [ + "min": String(AVSpeechUtteranceMinimumSpeechRate), + "normal": String(AVSpeechUtteranceDefaultSpeechRate), + "max": String(AVSpeechUtteranceMaximumSpeechRate), + "platform": "ios" + ] + result(validSpeechRateRange) + } + + private func isLanguageAvailable(language: String, result: FlutterResult) { + var isAvailable: Bool = false + if (self.languages.contains(where: {$0.range(of: language, options: [.caseInsensitive, .anchored]) != nil})) { + isAvailable = true + } + result(isAvailable); + } + + private func getVoices(result: FlutterResult) { + if #available(iOS 9.0, *) { + let voices = NSMutableArray() + var voiceDict: [String: String] = [:] + for voice in AVSpeechSynthesisVoice.speechVoices() { + voiceDict["name"] = voice.name + voiceDict["locale"] = voice.language + voiceDict["quality"] = voice.quality.stringValue + if #available(iOS 13.0, *) { + voiceDict["gender"] = voice.gender.stringValue + } + voiceDict["identifier"] = voice.identifier + voices.add(voiceDict) + } + result(voices) + } else { + // Since voice selection is not supported below iOS 9, make voice getter and setter + // have the same bahavior as language selection. + getLanguages(result: result) + } + } + + private func setVoice(voice: [String: String], result: FlutterResult) { + if #available(iOS 9.0, *) { + // Check if identifier exists and is not empty + if let identifier = voice["identifier"], !identifier.isEmpty { + // Find the voice by identifier + if let selectedVoice = AVSpeechSynthesisVoice(identifier: identifier) { + self.voice = selectedVoice + self.language = selectedVoice.language + result(1) + return + } + } + + // If no valid identifier, search by name and locale, then prioritize by quality + if let name = voice["name"], let locale = voice["locale"] { + let matchingVoices = AVSpeechSynthesisVoice.speechVoices().filter { $0.name == name && $0.language == locale } + + if !matchingVoices.isEmpty { + // Sort voices by quality: premium (if available) > enhanced > others + let sortedVoices = matchingVoices.sorted { (voice1, voice2) -> Bool in + let quality1 = voice1.quality + let quality2 = voice2.quality + + // macOS 13.0+ supports premium quality + if #available(iOS 16.0, *) { + if quality1 == .premium { + return true + } else if quality1 == .enhanced && quality2 != .premium { + return true + } else { + return false + } + } else { + // Fallback for macOS versions before 13.0 (no premium) + if quality1 == .enhanced { + return true + } else { + return false + } + } + } + + // Select the highest quality voice + if let selectedVoice = sortedVoices.first { + self.voice = selectedVoice + self.language = selectedVoice.language + result(1) + return + } + } + } + + // No matching voice found + result(0) + } else { + // Handle older iOS versions if needed + setLanguage(language: voice["name"]!, result: result) + } + } + + private func clearVoice() { + self.voice = nil + } + + private func shouldDeactivateAndNotifyOthers(_ session: AVAudioSession) -> Bool { + var options: AVAudioSession.CategoryOptions = .duckOthers + if #available(iOS 9.0, *) { + options.insert(.interruptSpokenAudioAndMixWithOthers) + } + options.remove(.mixWithOthers) + + return !options.isDisjoint(with: session.categoryOptions) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + if shouldDeactivateAndNotifyOthers(audioSession) && self.autoStopSharedSession { + do { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print(error) + } + } + if self.awaitSpeakCompletion && self.speakResult != nil { + self.speakResult!(1) + self.speakResult = nil + } + if self.awaitSynthCompletion && self.synthResult != nil { + self.synthResult!(1) + self.synthResult = nil + } + self.channel.invokeMethod("speak.onComplete", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onStart", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onPause", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onContinue", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onCancel", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { + let nsWord = utterance.speechString as NSString + let data: [String:String] = [ + "text": utterance.speechString, + "start": String(characterRange.location), + "end": String(characterRange.location + characterRange.length), + "word": nsWord.substring(with: characterRange) + ] + self.channel.invokeMethod("speak.onProgress", arguments: data) + } + +} + +extension AVSpeechSynthesisVoiceQuality { + var stringValue: String { + switch self { + case .default: + return "default" + case .premium: + return "premium" + case .enhanced: + return "enhanced" + } + } +} + +@available(iOS 13.0, *) +extension AVSpeechSynthesisVoiceGender { + var stringValue: String { + switch self { + case .male: + return "male" + case .female: + return "female" + case .unspecified: + return "unspecified" + } + } +} diff --git a/third_party/flutter_tts/ios/flutter_tts.podspec b/third_party/flutter_tts/ios/flutter_tts.podspec new file mode 100644 index 00000000..e46c3810 --- /dev/null +++ b/third_party/flutter_tts/ios/flutter_tts.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'flutter_tts' + s.version = '0.0.1' + s.summary = 'A flutter text to speech plugin.' + s.description = <<-DESC +A flutter text to speech plugin + DESC + s.homepage = 'https://github.com/dlutton/flutter_tts' + s.license = { :file => '../LICENSE' } + s.author = { 'eyedeadevelopment' => 'eyedea32@gmail.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.ios.deployment_target = '8.0' + s.swift_version = '4.2' + s.static_framework = true +end + diff --git a/third_party/flutter_tts/lib/flutter_tts.dart b/third_party/flutter_tts/lib/flutter_tts.dart new file mode 100644 index 00000000..e1eb5e73 --- /dev/null +++ b/third_party/flutter_tts/lib/flutter_tts.dart @@ -0,0 +1,668 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; + +typedef ErrorHandler = void Function(dynamic message); +typedef ProgressHandler = void Function( + String text, int start, int end, String word); + +const String iosAudioCategoryOptionsKey = 'iosAudioCategoryOptionsKey'; +const String iosAudioCategoryKey = 'iosAudioCategoryKey'; +const String iosAudioModeKey = 'iosAudioModeKey'; +const String iosAudioCategoryAmbientSolo = 'iosAudioCategoryAmbientSolo'; +const String iosAudioCategoryAmbient = 'iosAudioCategoryAmbient'; +const String iosAudioCategoryPlayback = 'iosAudioCategoryPlayback'; +const String iosAudioCategoryPlaybackAndRecord = + 'iosAudioCategoryPlaybackAndRecord'; + +const String iosAudioCategoryOptionsMixWithOthers = + 'iosAudioCategoryOptionsMixWithOthers'; +const String iosAudioCategoryOptionsDuckOthers = + 'iosAudioCategoryOptionsDuckOthers'; +const String iosAudioCategoryOptionsInterruptSpokenAudioAndMixWithOthers = + 'iosAudioCategoryOptionsInterruptSpokenAudioAndMixWithOthers'; +const String iosAudioCategoryOptionsAllowBluetooth = + 'iosAudioCategoryOptionsAllowBluetooth'; +const String iosAudioCategoryOptionsAllowBluetoothA2DP = + 'iosAudioCategoryOptionsAllowBluetoothA2DP'; +const String iosAudioCategoryOptionsAllowAirPlay = + 'iosAudioCategoryOptionsAllowAirPlay'; +const String iosAudioCategoryOptionsDefaultToSpeaker = + 'iosAudioCategoryOptionsDefaultToSpeaker'; + +const String iosAudioModeDefault = 'iosAudioModeDefault'; +const String iosAudioModeGameChat = 'iosAudioModeGameChat'; +const String iosAudioModeMeasurement = 'iosAudioModeMeasurement'; +const String iosAudioModeMoviePlayback = 'iosAudioModeMoviePlayback'; +const String iosAudioModeSpokenAudio = 'iosAudioModeSpokenAudio'; +const String iosAudioModeVideoChat = 'iosAudioModeVideoChat'; +const String iosAudioModeVideoRecording = 'iosAudioModeVideoRecording'; +const String iosAudioModeVoiceChat = 'iosAudioModeVoiceChat'; +const String iosAudioModeVoicePrompt = 'iosAudioModeVoicePrompt'; + +enum TextToSpeechPlatform { android, ios } + +/// Audio session category identifiers for iOS. +/// +/// See also: +/// * https://developer.apple.com/documentation/avfaudio/avaudiosession/category +enum IosTextToSpeechAudioCategory { + /// The default audio session category. + /// + /// Your audio is silenced by screen locking and by the Silent switch. + /// + /// By default, using this category implies that your app’s audio + /// is nonmixable—activating your session will interrupt + /// any other audio sessions which are also nonmixable. + /// To allow mixing, use the [ambient] category instead. + ambientSolo, + + /// The category for an app in which sound playback is nonprimary — that is, + /// your app also works with the sound turned off. + /// + /// This category is also appropriate for “play-along” apps, + /// such as a virtual piano that a user plays while the Music app is playing. + /// When you use this category, audio from other apps mixes with your audio. + /// Screen locking and the Silent switch (on iPhone, the Ring/Silent switch) silence your audio. + ambient, + + /// The category for playing recorded music or other sounds + /// that are central to the successful use of your app. + /// + /// When using this category, your app audio continues + /// with the Silent switch set to silent or when the screen locks. + /// + /// By default, using this category implies that your app’s audio + /// is nonmixable—activating your session will interrupt + /// any other audio sessions which are also nonmixable. + /// To allow mixing for this category, use the + /// [IosTextToSpeechAudioCategoryOptions.mixWithOthers] option. + playback, + + /// The category for recording (input) and playback (output) of audio, + /// such as for a Voice over Internet Protocol (VoIP) app. + /// + /// Your audio continues with the Silent switch set to silent and with the screen locked. + /// This category is appropriate for simultaneous recording and playback, + /// and also for apps that record and play back, but not simultaneously. + playAndRecord, +} + +/// Audio session mode identifiers for iOS. +/// +/// See also: +/// * https://developer.apple.com/documentation/avfaudio/avaudiosession/mode +enum IosTextToSpeechAudioMode { + /// The default audio session mode. + /// + /// You can use this mode with every [IosTextToSpeechAudioCategory]. + defaultMode, + + /// A mode that the GameKit framework sets on behalf of an application + /// that uses GameKit’s voice chat service. + /// + /// This mode is valid only with the + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// + /// Don’t set this mode directly. If you need similar behavior and aren’t + /// using a `GKVoiceChat` object, use [voiceChat] or [videoChat] instead. + gameChat, + + /// A mode that indicates that your app is performing measurement of audio input or output. + /// + /// Use this mode for apps that need to minimize the amount of + /// system-supplied signal processing to input and output signals. + /// If recording on devices with more than one built-in microphone, + /// the session uses the primary microphone. + /// + /// For use with the [IosTextToSpeechAudioCategory.playback] or + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// + /// **Important:** This mode disables some dynamics processing on input and output signals, + /// resulting in a lower-output playback level. + measurement, + + /// A mode that indicates that your app is playing back movie content. + /// + /// When you set this mode, the audio session uses signal processing to enhance + /// movie playback for certain audio routes such as built-in speaker or headphones. + /// You may only use this mode with the + /// [IosTextToSpeechAudioCategory.playback] category. + moviePlayback, + + /// A mode used for continuous spoken audio to pause the audio when another app plays a short audio prompt. + /// + /// This mode is appropriate for apps that play continuous spoken audio, + /// such as podcasts or audio books. Setting this mode indicates that your app + /// should pause, rather than duck, its audio if another app plays + /// a spoken audio prompt. After the interrupting app’s audio ends, you can + /// resume your app’s audio playback. + spokenAudio, + + /// A mode that indicates that your app is engaging in online video conferencing. + /// + /// Use this mode for video chat apps that use the + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// When you set this mode, the audio session optimizes the device’s tonal + /// equalization for voice. It also reduces the set of allowable audio routes + /// to only those appropriate for video chat. + /// + /// Using this mode has the side effect of enabling the + /// [IosTextToSpeechAudioCategoryOptions.allowBluetooth] category option. + videoChat, + + /// A mode that indicates that your app is recording a movie. + /// + /// This mode is valid only with the + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// On devices with more than one built-in microphone, + /// the audio session uses the microphone closest to the video camera. + /// + /// Use this mode to ensure that the system provides appropriate audio-signal processing. + videoRecording, + + /// A mode that indicates that your app is performing two-way voice communication, + /// such as using Voice over Internet Protocol (VoIP). + /// + /// Use this mode for Voice over IP (VoIP) apps that use the + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// When you set this mode, the session optimizes the device’s tonal + /// equalization for voice and reduces the set of allowable audio routes + /// to only those appropriate for voice chat. + /// + /// Using this mode has the side effect of enabling the + /// [IosTextToSpeechAudioCategoryOptions.allowBluetooth] category option. + voiceChat, + + /// A mode that indicates that your app plays audio using text-to-speech. + /// + /// Setting this mode allows for different routing behaviors when your app + /// is connected to certain audio devices, such as CarPlay. + /// An example of an app that uses this mode is a turn-by-turn navigation app + /// that plays short prompts to the user. + /// + /// Typically, apps of the same type also configure their sessions to use the + /// [IosTextToSpeechAudioCategoryOptions.duckOthers] and + /// [IosTextToSpeechAudioCategoryOptions.interruptSpokenAudioAndMixWithOthers] options. + voicePrompt, +} + +/// Audio session category options for iOS. +/// +/// See also: +/// * https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions +enum IosTextToSpeechAudioCategoryOptions { + /// An option that indicates whether audio from this session mixes with audio + /// from active sessions in other audio apps. + /// + /// You can set this option explicitly only if the audio session category + /// is [IosTextToSpeechAudioCategory.playAndRecord] or + /// [IosTextToSpeechAudioCategory.playback]. + /// If you set the audio session category to [IosTextToSpeechAudioCategory.ambient], + /// the session automatically sets this option. + /// Likewise, setting the [duckOthers] or [interruptSpokenAudioAndMixWithOthers] + /// options also enables this option. + /// + /// If you set this option, your app mixes its audio with audio playing + /// in background apps, such as the Music app. + mixWithOthers, + + /// An option that reduces the volume of other audio sessions while audio + /// from this session plays. + /// + /// You can set this option only if the audio session category is + /// [IosTextToSpeechAudioCategory.playAndRecord] or + /// [IosTextToSpeechAudioCategory.playback]. + /// Setting it implicitly sets the [mixWithOthers] option. + /// + /// Use this option to mix your app’s audio with that of others. + /// While your app plays its audio, the system reduces the volume of other + /// audio sessions to make yours more prominent. If your app provides + /// occasional spoken audio, such as in a turn-by-turn navigation app + /// or an exercise app, you should also set the [interruptSpokenAudioAndMixWithOthers] option. + /// + /// Note that ducking begins when you activate your app’s audio session + /// and ends when you deactivate the session. + /// + /// See also: + /// * [FlutterTts.setSharedInstance] + duckOthers, + + /// An option that determines whether to pause spoken audio content + /// from other sessions when your app plays its audio. + /// + /// You can set this option only if the audio session category is + /// [IosTextToSpeechAudioCategory.playAndRecord] or + /// [IosTextToSpeechAudioCategory.playback]. + /// Setting this option also sets [mixWithOthers]. + /// + /// If you set this option, the system mixes your audio with other + /// audio sessions, but interrupts (and stops) audio sessions that use the + /// [IosTextToSpeechAudioMode.spokenAudio] audio session mode. + /// It pauses the audio from other apps as long as your session is active. + /// After your audio session deactivates, the system resumes the interrupted app’s audio. + /// + /// Set this option if your app’s audio is occasional and spoken, + /// such as in a turn-by-turn navigation app or an exercise app. + /// This avoids intelligibility problems when two spoken audio apps mix. + /// If you set this option, also set the [duckOthers] option unless + /// you have a specific reason not to. Ducking other audio, rather than + /// interrupting it, is appropriate when the other audio isn’t spoken audio. + interruptSpokenAudioAndMixWithOthers, + + /// An option that determines whether Bluetooth hands-free devices appear + /// as available input routes. + /// + /// You can set this option only if the audio session category is + /// [IosTextToSpeechAudioCategory.playAndRecord] or + /// [IosTextToSpeechAudioCategory.playback]. + /// + /// You’re required to set this option to allow routing audio input and output + /// to a paired Bluetooth Hands-Free Profile (HFP) device. + /// If you clear this option, paired Bluetooth HFP devices don’t show up + /// as available audio input routes. + allowBluetooth, + + /// An option that determines whether you can stream audio from this session + /// to Bluetooth devices that support the Advanced Audio Distribution Profile (A2DP). + /// + /// A2DP is a stereo, output-only profile intended for higher bandwidth + /// audio use cases, such as music playback. + /// The system automatically routes to A2DP ports if you configure an + /// app’s audio session to use the [IosTextToSpeechAudioCategory.ambient], + /// [IosTextToSpeechAudioCategory.ambientSolo], or + /// [IosTextToSpeechAudioCategory.playback] categories. + /// + /// Starting with iOS 10.0, apps using the + /// [IosTextToSpeechAudioCategory.playAndRecord] category may also allow + /// routing output to paired Bluetooth A2DP devices. To enable this behavior, + /// pass this category option when setting your audio session’s category. + /// + /// Note: If this option and the [allowBluetooth] option are both set, + /// when a single device supports both the Hands-Free Profile (HFP) and A2DP, + /// the system gives hands-free ports a higher priority for routing. + allowBluetoothA2DP, + + /// An option that determines whether you can stream audio + /// from this session to AirPlay devices. + /// + /// Setting this option enables the audio session to route audio output + /// to AirPlay devices. You can only explicitly set this option if the + /// audio session’s category is set to [IosTextToSpeechAudioCategory.playAndRecord]. + /// For most other audio session categories, the system sets this option implicitly. + allowAirPlay, + + /// An option that determines whether audio from the session defaults to the built-in speaker instead of the receiver. + /// + /// You can set this option only when using the + /// [IosTextToSpeechAudioCategory.playAndRecord] category. + /// It’s used to modify the category’s routing behavior so that audio + /// is always routed to the speaker rather than the receiver if + /// no other accessories, such as headphones, are in use. + /// + /// When using this option, the system honors user gestures. + /// For example, plugging in a headset causes the route to change to + /// headset mic/headphones, and unplugging the headset causes the route + /// to change to built-in mic/speaker (as opposed to built-in mic/receiver) + /// when you’ve set this override. + /// + /// In the case of using a USB input-only accessory, audio input + /// comes from the accessory, and the system routes audio to the headphones, + /// if attached, or to the speaker if the headphones aren’t plugged in. + /// The use case is to route audio to the speaker instead of the receiver + /// in cases where the audio would normally go to the receiver. + defaultToSpeaker, +} + +class SpeechRateValidRange { + final double min; + final double normal; + final double max; + final TextToSpeechPlatform platform; + + SpeechRateValidRange(this.min, this.normal, this.max, this.platform); +} + +// Provides Platform specific TTS services (Android: TextToSpeech, IOS: AVSpeechSynthesizer) +class FlutterTts { + static const MethodChannel _channel = MethodChannel('flutter_tts'); + + VoidCallback? startHandler; + VoidCallback? completionHandler; + VoidCallback? pauseHandler; + VoidCallback? continueHandler; + VoidCallback? cancelHandler; + ProgressHandler? progressHandler; + ErrorHandler? errorHandler; + + FlutterTts() { + _channel.setMethodCallHandler(platformCallHandler); + } + + /// [Future] which sets speak's future to return on completion of the utterance + Future awaitSpeakCompletion(bool awaitCompletion) async => + await _channel.invokeMethod('awaitSpeakCompletion', awaitCompletion); + + /// [Future] which sets synthesize to file's future to return on completion of the synthesize + /// ***Android, iOS, and macOS supported only*** + Future awaitSynthCompletion(bool awaitCompletion) async => + await _channel.invokeMethod('awaitSynthCompletion', awaitCompletion); + + /// [Future] which invokes the platform specific method for speaking + Future speak(String text, {bool focus = false}) async { + if (!kIsWeb && Platform.isAndroid) { + return await _channel.invokeMethod('speak', { + "text": text, + "focus": focus, + }); + } else { + return await _channel.invokeMethod('speak', text); + } + } + + /// [Future] which invokes the platform specific method for pause + Future pause() async => await _channel.invokeMethod('pause'); + + /// [Future] which invokes the platform specific method for getMaxSpeechInputLength + /// ***Android supported only*** + Future get getMaxSpeechInputLength async { + return await _channel.invokeMethod('getMaxSpeechInputLength'); + } + + /// [Future] which invokes the platform specific method for synthesizeToFile + /// ***Android and iOS supported only*** + Future synthesizeToFile(String text, String fileName, + [bool isFullPath = false]) async => + _channel.invokeMethod('synthesizeToFile', { + "text": text, + "fileName": fileName, + "isFullPath": isFullPath, + }); + + /// [Future] which invokes the platform specific method for setLanguage + Future setLanguage(String language) async => + await _channel.invokeMethod('setLanguage', language); + + /// [Future] which invokes the platform specific method for setSpeechRate + /// Allowed values are in the range from 0.0 (slowest) to 1.0 (fastest) + Future setSpeechRate(double rate) async => + await _channel.invokeMethod('setSpeechRate', rate); + + /// [Future] which invokes the platform specific method for setVolume + /// Allowed values are in the range from 0.0 (silent) to 1.0 (loudest) + Future setVolume(double volume) async => + await _channel.invokeMethod('setVolume', volume); + + /// [Future] which invokes the platform specific method for shared instance + /// ***iOS supported only*** + Future setSharedInstance(bool sharedSession) async => + await _channel.invokeMethod('setSharedInstance', sharedSession); + + /// [Future] which invokes the platform specific method for setting the autoStopSharedSession + /// default value is true + /// *** iOS, and macOS supported only*** + Future autoStopSharedSession(bool autoStop) async => + await _channel.invokeMethod('autoStopSharedSession', autoStop); + + /// [Future] which invokes the platform specific method for setting audio category + /// ***Ios supported only*** + Future setIosAudioCategory(IosTextToSpeechAudioCategory category, + List options, + [IosTextToSpeechAudioMode mode = + IosTextToSpeechAudioMode.defaultMode]) async { + const categoryToString = { + IosTextToSpeechAudioCategory.ambientSolo: iosAudioCategoryAmbientSolo, + IosTextToSpeechAudioCategory.ambient: iosAudioCategoryAmbient, + IosTextToSpeechAudioCategory.playback: iosAudioCategoryPlayback, + IosTextToSpeechAudioCategory.playAndRecord: + iosAudioCategoryPlaybackAndRecord, + }; + const optionsToString = { + IosTextToSpeechAudioCategoryOptions.mixWithOthers: + 'iosAudioCategoryOptionsMixWithOthers', + IosTextToSpeechAudioCategoryOptions.duckOthers: + 'iosAudioCategoryOptionsDuckOthers', + IosTextToSpeechAudioCategoryOptions.interruptSpokenAudioAndMixWithOthers: + 'iosAudioCategoryOptionsInterruptSpokenAudioAndMixWithOthers', + IosTextToSpeechAudioCategoryOptions.allowBluetooth: + 'iosAudioCategoryOptionsAllowBluetooth', + IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP: + 'iosAudioCategoryOptionsAllowBluetoothA2DP', + IosTextToSpeechAudioCategoryOptions.allowAirPlay: + 'iosAudioCategoryOptionsAllowAirPlay', + IosTextToSpeechAudioCategoryOptions.defaultToSpeaker: + 'iosAudioCategoryOptionsDefaultToSpeaker', + }; + const modeToString = { + IosTextToSpeechAudioMode.defaultMode: iosAudioModeDefault, + IosTextToSpeechAudioMode.gameChat: iosAudioModeGameChat, + IosTextToSpeechAudioMode.measurement: iosAudioModeMeasurement, + IosTextToSpeechAudioMode.moviePlayback: iosAudioModeMoviePlayback, + IosTextToSpeechAudioMode.spokenAudio: iosAudioModeSpokenAudio, + IosTextToSpeechAudioMode.videoChat: iosAudioModeVideoChat, + IosTextToSpeechAudioMode.videoRecording: iosAudioModeVideoRecording, + IosTextToSpeechAudioMode.voiceChat: iosAudioModeVoiceChat, + IosTextToSpeechAudioMode.voicePrompt: iosAudioModeVoicePrompt, + }; + if (!Platform.isIOS) return; + try { + return await _channel + .invokeMethod('setIosAudioCategory', { + iosAudioCategoryKey: categoryToString[category], + iosAudioCategoryOptionsKey: + options.map((o) => optionsToString[o]).toList(), + iosAudioModeKey: modeToString[mode], + }); + } on PlatformException catch (e) { + print( + 'setIosAudioCategory error, category: $category, mode: $mode, error: ${e.message}'); + } + } + + /// [Future] which invokes the platform specific method for setEngine + /// ***Android supported only*** + Future setEngine(String engine) async { + await _channel.invokeMethod('setEngine', engine); + } + + /// [Future] which invokes the platform specific method for setPitch + /// 1.0 is default and ranges from .5 to 2.0 + Future setPitch(double pitch) async => + await _channel.invokeMethod('setPitch', pitch); + + /// [Future] which invokes the platform specific method for setVoice + /// ***Android, iOS, and macOS supported only*** + Future setVoice(Map voice) async => + await _channel.invokeMethod('setVoice', voice); + + /// [Future] which resets the platform voice to the default + Future clearVoice() async => + await _channel.invokeMethod('clearVoice'); + + /// [Future] which invokes the platform specific method for stop + Future stop() async => await _channel.invokeMethod('stop'); + + /// [Future] which invokes the platform specific method for getLanguages + /// Android issues with API 21 & 22 + /// Returns a list of available languages + Future get getLanguages async { + final languages = await _channel.invokeMethod('getLanguages'); + return languages; + } + + /// [Future] which invokes the platform specific method for getEngines + /// Returns a list of installed TTS engines + /// ***Android supported only*** + Future get getEngines async { + final engines = await _channel.invokeMethod('getEngines'); + return engines; + } + + /// [Future] which invokes the platform specific method for getDefaultEngine + /// Returns a `String` of the default engine name + /// ***Android supported only *** + Future get getDefaultEngine async { + final engineName = await _channel.invokeMethod('getDefaultEngine'); + return engineName; + } + + /// [Future] which invokes the platform specific method for getDefaultVoice + /// Returns a `Map` containing a voice name and locale + /// ***Android supported only *** + Future get getDefaultVoice async { + final voice = await _channel.invokeMethod('getDefaultVoice'); + return voice; + } + + /// [Future] which invokes the platform specific method for getVoices + /// Returns a `List` of `Maps` containing a voice name and locale + /// For iOS specifically, it also includes quality, gender, and identifier + /// ***Android, iOS, and macOS supported only*** + Future get getVoices async { + final voices = await _channel.invokeMethod('getVoices'); + return voices; + } + + /// [Future] which invokes the platform specific method for isLanguageAvailable + /// Returns `true` or `false` + Future isLanguageAvailable(String language) async => + await _channel.invokeMethod('isLanguageAvailable', language); + + /// [Future] which invokes the platform specific method for isLanguageInstalled + /// Returns `true` or `false` + /// ***Android supported only*** + Future isLanguageInstalled(String language) async => + await _channel.invokeMethod('isLanguageInstalled', language); + + /// [Future] which invokes the platform specific method for areLanguagesInstalled + /// Returns a HashMap with `true` or `false` for each submitted language. + /// ***Android supported only*** + Future areLanguagesInstalled(List languages) async => + await _channel.invokeMethod('areLanguagesInstalled', languages); + + Future get getSpeechRateValidRange async { + final validRange = await _channel.invokeMethod('getSpeechRateValidRange') + as Map; + final min = double.parse(validRange['min'].toString()); + final normal = double.parse(validRange['normal'].toString()); + final max = double.parse(validRange['max'].toString()); + final platformStr = validRange['platform'].toString(); + final platform = + TextToSpeechPlatform.values.firstWhere((e) => e.name == platformStr); + + return SpeechRateValidRange(min, normal, max, platform); + } + + /// [Future] which invokes the platform specific method for setSilence + /// 0 means start the utterance immediately. If the value is greater than zero a silence period in milliseconds is set according to the parameter + /// ***Android supported only*** + Future setSilence(int timems) async => + await _channel.invokeMethod('setSilence', timems); + + /// [Future] which invokes the platform specific method for setQueueMode + /// 0 means QUEUE_FLUSH - Queue mode where all entries in the playback queue (media to be played and text to be synthesized) are dropped and replaced by the new entry. + /// Queues are flushed with respect to a given calling app. Entries in the queue from other calls are not discarded. + /// 1 means QUEUE_ADD - Queue mode where the new entry is added at the end of the playback queue. + /// ***Android supported only*** + Future setQueueMode(int queueMode) async => + await _channel.invokeMethod('setQueueMode', queueMode); + + void setStartHandler(VoidCallback callback) { + startHandler = callback; + } + + void setCompletionHandler(VoidCallback callback) { + completionHandler = callback; + } + + void setContinueHandler(VoidCallback callback) { + continueHandler = callback; + } + + void setPauseHandler(VoidCallback callback) { + pauseHandler = callback; + } + + void setCancelHandler(VoidCallback callback) { + cancelHandler = callback; + } + + void setProgressHandler(ProgressHandler callback) { + progressHandler = callback; + } + + void setErrorHandler(ErrorHandler handler) { + errorHandler = handler; + } + + /// Platform listeners + Future platformCallHandler(MethodCall call) async { + switch (call.method) { + case "speak.onStart": + if (startHandler != null) { + startHandler!(); + } + break; + + case "synth.onStart": + if (startHandler != null) { + startHandler!(); + } + break; + case "speak.onComplete": + if (completionHandler != null) { + completionHandler!(); + } + break; + case "synth.onComplete": + if (completionHandler != null) { + completionHandler!(); + } + break; + case "speak.onPause": + if (pauseHandler != null) { + pauseHandler!(); + } + break; + case "speak.onContinue": + if (continueHandler != null) { + continueHandler!(); + } + break; + case "speak.onCancel": + if (cancelHandler != null) { + cancelHandler!(); + } + break; + case "speak.onError": + if (errorHandler != null) { + errorHandler!(call.arguments); + } + break; + case 'speak.onProgress': + if (progressHandler != null) { + final args = call.arguments as Map; + progressHandler!( + args['text'].toString(), + int.parse(args['start'].toString()), + int.parse(args['end'].toString()), + args['word'].toString(), + ); + } + break; + case "synth.onError": + if (errorHandler != null) { + errorHandler!(call.arguments); + } + break; + default: + print('Unknown method ${call.method}'); + } + } + + Future setAudioAttributesForNavigation() async { + await _channel.invokeMethod('setAudioAttributesForNavigation'); + } +} diff --git a/third_party/flutter_tts/lib/flutter_tts_web.dart b/third_party/flutter_tts/lib/flutter_tts_web.dart new file mode 100644 index 00000000..abff5ad3 --- /dev/null +++ b/third_party/flutter_tts/lib/flutter_tts_web.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +import 'interop_types.dart'; + +enum TtsState { playing, stopped, paused, continued } + +class FlutterTtsPlugin { + static const String platformChannel = "flutter_tts"; + static late MethodChannel channel; + bool awaitSpeakCompletion = false; + + TtsState ttsState = TtsState.stopped; + + Completer? _speechCompleter; + + bool get isPlaying => ttsState == TtsState.playing; + + bool get isStopped => ttsState == TtsState.stopped; + + bool get isPaused => ttsState == TtsState.paused; + + bool get isContinued => ttsState == TtsState.continued; + + static void registerWith(Registrar registrar) { + channel = + MethodChannel(platformChannel, const StandardMethodCodec(), registrar); + final instance = FlutterTtsPlugin(); + channel.setMethodCallHandler(instance.handleMethodCall); + } + + late final SpeechSynthesisUtterance utterance; + List voices = []; + List languages = []; + Timer? t; + bool supported = false; + + FlutterTtsPlugin() { + try { + utterance = SpeechSynthesisUtterance(); + _listeners(); + supported = true; + } catch (e) { + print('Initialization of TTS failed. Functions are disabled. Error: $e'); + } + } + + void _listeners() { + utterance.onStart = (JSAny e) { + ttsState = TtsState.playing; + channel.invokeMethod("speak.onStart", null); + var bLocal = (utterance.voice?.isLocalService ?? false); + if (!bLocal) { + t = Timer.periodic(Duration(seconds: 14), (t) { + if (ttsState == TtsState.playing) { + synth.pause(); + synth.resume(); + } else { + t.cancel(); + } + }); + } + }.toJS; + // js.JsFunction.withThis((e) { + // ttsState = TtsState.playing; + // channel.invokeMethod("speak.onStart", null); + // }); + utterance.onEnd = (JSAny e) { + ttsState = TtsState.stopped; + if (_speechCompleter != null) { + _speechCompleter?.complete(); + _speechCompleter = null; + } + t?.cancel(); + channel.invokeMethod("speak.onComplete", null); + }.toJS; + + utterance.onPause = (JSAny e) { + ttsState = TtsState.paused; + channel.invokeMethod("speak.onPause", null); + }.toJS; + + utterance.onResume = (JSAny e) { + ttsState = TtsState.continued; + channel.invokeMethod("speak.onContinue", null); + }.toJS; + + utterance.onError = (JSObject event) { + ttsState = TtsState.stopped; + if (_speechCompleter != null) { + _speechCompleter = null; + } + t?.cancel(); + print(event); // Log the entire event object to get more details + channel.invokeMethod("speak.onError", event["error"]); + }.toJS; + + utterance.onBoundary = (JSObject event) { + int charIndex = event['charIndex'] as int; + String name = event['name'] as String; + if (name == 'sentence') return; + String text = utterance['text'] as String; + int endIndex = charIndex; + while (endIndex < text.length && + !RegExp(r'[\s,.!?]').hasMatch(text[endIndex])) { + endIndex++; + } + String word = text.substring(charIndex, endIndex); + Map progressArgs = { + 'text': text, + 'start': charIndex, + 'end': endIndex, + 'word': word + }; + channel.invokeMethod("speak.onProgress", progressArgs); + }.toJS; + } + + Future handleMethodCall(MethodCall call) async { + if (!supported) return; + switch (call.method) { + case 'speak': + final text = call.arguments as String?; + if (awaitSpeakCompletion) { + _speechCompleter = Completer(); + } + _speak(text); + if (awaitSpeakCompletion) { + return _speechCompleter?.future; + } + break; + case 'awaitSpeakCompletion': + awaitSpeakCompletion = (call.arguments as bool?) ?? false; + return 1; + case 'stop': + _stop(); + return 1; + case 'pause': + _pause(); + return 1; + case 'setLanguage': + final language = call.arguments as String; + _setLanguage(language); + return 1; + case 'getLanguages': + return _getLanguages(); + case 'getVoices': + return getVoices(); + case 'setVoice': + final tmpVoiceMap = + Map.from(call.arguments as LinkedHashMap); + return _setVoice(tmpVoiceMap); + case 'setSpeechRate': + final rate = call.arguments as double; + _setRate(rate); + return 1; + case 'setVolume': + final volume = call.arguments as double; + _setVolume(volume); + return 1; + case 'setPitch': + final pitch = call.arguments as double; + _setPitch(pitch); + return 1; + case 'isLanguageAvailable': + final lang = call.arguments as String; + return _isLanguageAvailable(lang); + default: + throw PlatformException( + code: 'Unimplemented', + details: "The flutter_tts plugin for web doesn't implement " + "the method '${call.method}'"); + } + } + + void _speak(String? text) { + if (text == null || text.isEmpty) return; + if (ttsState == TtsState.stopped || ttsState == TtsState.paused) { + utterance.text = text; + if (ttsState == TtsState.paused) { + synth.resume(); + } else { + synth.speak(utterance); + } + } + } + + void _stop() { + if (ttsState != TtsState.stopped) { + synth.cancel(); + } + } + + void _pause() { + if (ttsState == TtsState.playing || ttsState == TtsState.continued) { + synth.pause(); + } + } + + void _setRate(double rate) => utterance.rate = rate; + void _setVolume(double volume) => utterance.volume = volume; + void _setPitch(double pitch) => utterance.pitch = pitch; + void _setLanguage(String language) { + var targetList = synth.getVoices().toDart.where((e) { + return e.lang.toLowerCase().startsWith(language.toLowerCase()); + }); + if (targetList.isNotEmpty) { + utterance.voice = targetList.first; + utterance.lang = targetList.first.lang; + } + } + + void _setVoice(Map voice) { + var tmpVoices = synth.getVoices().toDart; + var targetList = tmpVoices.where((e) { + return voice["name"] == e.name && voice["locale"] == e.lang; + }); + if (targetList.isNotEmpty) { + utterance.voice = targetList.first; + } + } + + bool _isLanguageAvailable(String? language) { + if (voices.isEmpty) _setVoices(); + if (languages.isEmpty) _setLanguages(); + for (var lang in languages) { + if (!language!.contains('-')) { + lang = lang.split('-').first; + } + if (lang.toLowerCase() == language.toLowerCase()) return true; + } + return false; + } + + List? _getLanguages() { + if (voices.isEmpty) _setVoices(); + if (languages.isEmpty) _setLanguages(); + return languages; + } + + void _setVoices() { + voices = synth.getVoices().toDart; + } + + Future>> getVoices() async { + var tmpVoices = synth.getVoices().toDart; + return tmpVoices + .map((voice) => {"name": voice.name, "locale": voice.lang}) + .toList(); + } + + void _setLanguages() { + var langs = {}; + for (var v in voices) { + langs.add(v.lang); + } + + languages = langs.toList(); + } +} diff --git a/third_party/flutter_tts/lib/interop_types.dart b/third_party/flutter_tts/lib/interop_types.dart new file mode 100644 index 00000000..431a7a10 --- /dev/null +++ b/third_party/flutter_tts/lib/interop_types.dart @@ -0,0 +1,67 @@ +import 'dart:js_interop'; + +@JS('speechSynthesis') +external SpeechSynthesis get synth; + +@JS() +extension type SpeechSynthesis._(JSObject _) implements JSObject { + external void cancel(); + + external JSArray getVoices(); + + external void pause(); + + external void resume(); + + external void speak(SpeechSynthesisUtterance utterance); +} + +@JS() +extension type SpeechSynthesisUtterance._(JSObject _) implements JSObject { + external SpeechSynthesisUtterance(); + + external String lang; + + external double pitch; + + external double rate; + + external String text; + + external SpeechSynthesisVoice? voice; + + external double volume; + + // Event listeners + + @JS('onstart') + external set onStart(JSFunction listener); + + @JS('onend') + external set onEnd(JSFunction listener); + + @JS('onpause') + external set onPause(JSFunction listener); + + @JS('onresume') + external set onResume(JSFunction listener); + + @JS('onerror') + external set onError(JSFunction listener); + + @JS('onboundary') + external set onBoundary(JSFunction listener); +} + +@JS() +extension type SpeechSynthesisVoice._(JSObject _) implements JSObject { + @JS('default') + external bool get isDefault; + + external String get lang; + + @JS('localService') + external bool get isLocalService; + + external String get name; +} diff --git a/third_party/flutter_tts/macos/Classes/FlutterTtsPlugin.swift b/third_party/flutter_tts/macos/Classes/FlutterTtsPlugin.swift new file mode 100644 index 00000000..f30bca60 --- /dev/null +++ b/third_party/flutter_tts/macos/Classes/FlutterTtsPlugin.swift @@ -0,0 +1,411 @@ +import FlutterMacOS +import Foundation +import AVFoundation + +public class FlutterTtsPlugin: NSObject, FlutterPlugin, AVSpeechSynthesizerDelegate { + final var iosAudioCategoryKey = "iosAudioCategoryKey" + final var iosAudioCategoryOptionsKey = "iosAudioCategoryOptionsKey" + + let synthesizer = AVSpeechSynthesizer() + var language: String = AVSpeechSynthesisVoice.currentLanguageCode() + var rate: Float = AVSpeechUtteranceDefaultSpeechRate + var languages = Set() + var volume: Float = 1.0 + var pitch: Float = 1.0 + var voice: AVSpeechSynthesisVoice? + var awaitSpeakCompletion: Bool = false + var awaitSynthCompletion: Bool = false + var speakResult: FlutterResult! + var synthResult: FlutterResult! + + var channel = FlutterMethodChannel() + init(channel: FlutterMethodChannel) { + super.init() + self.channel = channel + synthesizer.delegate = self + setLanguages() + } + + private func setLanguages() { + for voice in AVSpeechSynthesisVoice.speechVoices(){ + self.languages.insert(voice.language) + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_tts", binaryMessenger: registrar.messenger) + let instance = FlutterTtsPlugin(channel: channel) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "speak": + let text: String = call.arguments as! String + self.speak(text: text, result: result) + break + case "awaitSpeakCompletion": + self.awaitSpeakCompletion = call.arguments as! Bool + result(1) + break + case "awaitSynthCompletion": + self.awaitSynthCompletion = call.arguments as! Bool + result(1) + break + case "synthesizeToFile": + guard let args = call.arguments as? [String: Any] else { + result("iOS could not recognize flutter arguments in method: (sendParams)") + return + } + let text = args["text"] as! String + let fileName = args["fileName"] as! String + self.synthesizeToFile(text: text, fileName: fileName, result: result) + break + case "pause": + self.pause(result: result) + break + case "setLanguage": + let language: String = call.arguments as! String + self.setLanguage(language: language, result: result) + break + case "setSpeechRate": + let rate: Double = call.arguments as! Double + self.setRate(rate: Float(rate)) + result(1) + break + case "setVolume": + let volume: Double = call.arguments as! Double + self.setVolume(volume: Float(volume), result: result) + break + case "setPitch": + let pitch: Double = call.arguments as! Double + self.setPitch(pitch: Float(pitch), result: result) + break + case "stop": + self.stop() + result(1) + break + case "getLanguages": + self.getLanguages(result: result) + break + case "getSpeechRateValidRange": + self.getSpeechRateValidRange(result: result) + break + case "isLanguageAvailable": + let language: String = call.arguments as! String + self.isLanguageAvailable(language: language, result: result) + break + case "getVoices": + self.getVoices(result: result) + break + case "setVoice": + guard let args = call.arguments as? [String: String] else { + result("iOS could not recognize flutter arguments in method: (sendParams)") + return + } + self.setVoice(voice: args, result: result) + break + case "autoStopSharedSession": + // MacOS does not have a shared audio session so just accept the call + result(1) + break + default: + result(FlutterMethodNotImplemented) + } + } + + private func speak(text: String, result: @escaping FlutterResult) { + if (self.synthesizer.isPaused) { + if (self.synthesizer.continueSpeaking()) { + if self.awaitSpeakCompletion { + self.speakResult = result + } else { + result(1) + } + } else { + result(0) + } + } else { + let utterance = AVSpeechUtterance(string: text) + if self.voice != nil { + utterance.voice = self.voice! + } else { + utterance.voice = AVSpeechSynthesisVoice(language: self.language) + } + utterance.rate = self.rate + utterance.volume = self.volume + utterance.pitchMultiplier = self.pitch + + self.synthesizer.speak(utterance) + if self.awaitSpeakCompletion { + self.speakResult = result + } else { + result(1) + } + } + } + + private func synthesizeToFile(text: String, fileName: String, result: @escaping FlutterResult) { + var output: AVAudioFile? + var failed = false + let utterance = AVSpeechUtterance(string: text) + + if #available(iOS 13.0, *) { + self.synthesizer.write(utterance) { (buffer: AVAudioBuffer) in + guard let pcmBuffer = buffer as? AVAudioPCMBuffer else { + NSLog("unknow buffer type: \(buffer)") + failed = true + return + } + if pcmBuffer.frameLength == 0 { + // finished + } else { + // append buffer to file + let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + NSLog("Saving utterance to file: \(fileURL.absoluteString)") + + if output == nil { + do { + output = try AVAudioFile( + forWriting: fileURL, + settings: pcmBuffer.format.settings, + commonFormat: .pcmFormatFloat32, + interleaved: false) + } catch { + NSLog(error.localizedDescription) + failed = true + return + } + } + + try! output!.write(from: pcmBuffer) + } + } + } else { + result("Unsupported iOS version") + } + if failed { + result(0) + } + if self.awaitSynthCompletion { + self.synthResult = result + } else { + result(1) + } + } + + private func pause(result: FlutterResult) { + if (self.synthesizer.pauseSpeaking(at: AVSpeechBoundary.word)) { + result(1) + } else { + result(0) + } + } + + private func setLanguage(language: String, result: FlutterResult) { + if !(self.languages.contains(where: {$0.range(of: language, options: [.caseInsensitive, .anchored]) != nil})) { + result(0) + } else { + self.language = language + self.voice = nil + result(1) + } + } + + private func setRate(rate: Float) { + self.rate = rate + } + + private func setVolume(volume: Float, result: FlutterResult) { + if (volume >= 0.0 && volume <= 1.0) { + self.volume = volume + result(1) + } else { + result(0) + } + } + + private func setPitch(pitch: Float, result: FlutterResult) { + if (volume >= 0.5 && volume <= 2.0) { + self.pitch = pitch + result(1) + } else { + result(0) + } + } + + private func stop() { + self.synthesizer.stopSpeaking(at: AVSpeechBoundary.immediate) + } + + private func getLanguages(result: FlutterResult) { + result(Array(self.languages)) + } + + private func getSpeechRateValidRange(result: FlutterResult) { + let validSpeechRateRange: [String:String] = [ + "min": String(AVSpeechUtteranceMinimumSpeechRate), + "normal": String(AVSpeechUtteranceDefaultSpeechRate), + "max": String(AVSpeechUtteranceMaximumSpeechRate), + "platform": "ios" + ] + result(validSpeechRateRange) + } + + private func isLanguageAvailable(language: String, result: FlutterResult) { + var isAvailable: Bool = false + if (self.languages.contains(where: {$0.range(of: language, options: [.caseInsensitive, .anchored]) != nil})) { + isAvailable = true + } + result(isAvailable); + } + + private func getVoices(result: FlutterResult) { + if #available(macOS 10.15, *) { + let voices = NSMutableArray() + var voiceDict: [String: String] = [:] + for voice in AVSpeechSynthesisVoice.speechVoices() { + voiceDict["name"] = voice.name + voiceDict["locale"] = voice.language + voiceDict["quality"] = voice.quality.stringValue + if #available(macOS 10.15, *) { + voiceDict["gender"] = voice.gender.stringValue + } + voiceDict["identifier"] = voice.identifier + voices.add(voiceDict) + } + result(voices) + } else { + // Since voice selection is not supported below iOS 9, make voice getter and setter + // have the same bahavior as language selection. + getLanguages(result: result) + } + } + + + + private func setVoice(voice: [String: String], result: FlutterResult) { + if #available(iOS 9.0, *) { + // Check if identifier exists and is not empty + if let identifier = voice["identifier"], !identifier.isEmpty { + // Find the voice by identifier + if let selectedVoice = AVSpeechSynthesisVoice(identifier: identifier) { + self.voice = selectedVoice + self.language = selectedVoice.language + result(1) + return + } + } + + // If no valid identifier, search by name and locale, then prioritize by quality + if let name = voice["name"], let locale = voice["locale"] { + let matchingVoices = AVSpeechSynthesisVoice.speechVoices().filter { $0.name == name && $0.language == locale } + + if !matchingVoices.isEmpty { + // Sort voices by quality: premium (if available) > enhanced > others + let sortedVoices = matchingVoices.sorted { (voice1, voice2) -> Bool in + let quality1 = voice1.quality + let quality2 = voice2.quality + + // macOS 13.0+ supports premium quality + if #available(macOS 13.0, *) { + if quality1 == .premium { + return true + } else if quality1 == .enhanced && quality2 != .premium { + return true + } else { + return false + } + } else { + // Fallback for macOS versions before 13.0 (no premium) + if quality1 == .enhanced { + return true + } else { + return false + } + } + } + + // Select the highest quality voice + if let selectedVoice = sortedVoices.first { + self.voice = selectedVoice + self.language = selectedVoice.language + result(1) + return + } + } + } + + // No matching voice found + result(0) + } else { + // Handle older iOS versions if needed + setLanguage(language: voice["name"]!, result: result) + } + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + if self.awaitSpeakCompletion { + self.speakResult(1) + } + if self.awaitSynthCompletion { + self.synthResult(1) + } + self.channel.invokeMethod("speak.onComplete", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onStart", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onPause", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onContinue", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + self.channel.invokeMethod("speak.onCancel", arguments: nil) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { + let nsWord = utterance.speechString as NSString + let data: [String:String] = [ + "text": utterance.speechString, + "start": String(characterRange.location), + "end": String(characterRange.location + characterRange.length), + "word": nsWord.substring(with: characterRange) + ] + self.channel.invokeMethod("speak.onProgress", arguments: data) + } + +} + +extension AVSpeechSynthesisVoiceQuality { + var stringValue: String { + switch self { + case .default: + return "default" + case .premium: + return "premium" + case .enhanced: + return "enhanced" + } + } +} + +@available(macOS 10.15, *) +extension AVSpeechSynthesisVoiceGender { + var stringValue: String { + switch self { + case .male: + return "male" + case .female: + return "female" + case .unspecified: + return "unspecified" + } + } +} diff --git a/third_party/flutter_tts/macos/flutter_tts.podspec b/third_party/flutter_tts/macos/flutter_tts.podspec new file mode 100644 index 00000000..2838eb07 --- /dev/null +++ b/third_party/flutter_tts/macos/flutter_tts.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'flutter_tts' + s.version = '0.0.1' + s.summary = 'macOS implementation of the flutter_tts plugin.' + s.description = <<-DESC + A flutter text to speech plugin + DESC + s.homepage = 'https://github.com/dlutton/flutter_tts' + s.license = { :file => '../LICENSE' } + s.author = { 'Daniel Lutton' => 'eyedea32@gmail.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.15' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + end \ No newline at end of file diff --git a/third_party/flutter_tts/pubspec.yaml b/third_party/flutter_tts/pubspec.yaml new file mode 100644 index 00000000..f9da5647 --- /dev/null +++ b/third_party/flutter_tts/pubspec.yaml @@ -0,0 +1,35 @@ +name: flutter_tts +description: A flutter plugin for Text to Speech. This plugin is supported on iOS, macOS, Android, Web, & Windows. +version: 4.2.5 +homepage: https://github.com/dlutton/flutter_tts + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.eyedeadevelopment.fluttertts + pluginClass: FlutterTtsPlugin + ios: + pluginClass: FlutterTtsPlugin + macos: + pluginClass: FlutterTtsPlugin + windows: + pluginClass: FlutterTtsPlugin + supportedVariants: + - uwp + - win32 + web: + pluginClass: FlutterTtsPlugin + fileName: flutter_tts_web.dart + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=1.22.0" +dev_dependencies: + lints: ^3.0.0 diff --git a/third_party/flutter_tts/windows/CMakeLists.txt b/third_party/flutter_tts/windows/CMakeLists.txt new file mode 100644 index 00000000..d278b7d2 --- /dev/null +++ b/third_party/flutter_tts/windows/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.14) + +if(POLICY CMP0153) + cmake_policy(SET CMP0153 NEW) +endif() + +set(PROJECT_NAME "flutter_tts") +project(${PROJECT_NAME} LANGUAGES CXX) + +find_program(NUGET_EXE NAMES nuget) +if(NOT NUGET_EXE) + message(FATAL_ERROR "nuget.exe not found. Please install it.") +endif() + +execute_process( + COMMAND ${NUGET_EXE} install Microsoft.Windows.CppWinRT + -Version 2.0.210503.1 + -ExcludeVersion + -OutputDirectory ${CMAKE_BINARY_DIR}/packages +) + +set(PLUGIN_NAME "flutter_tts_plugin") + +add_library(${PLUGIN_NAME} SHARED + "flutter_tts_plugin.cpp" +) + +apply_standard_settings(${PLUGIN_NAME}) + +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES +) + +target_compile_features(${PLUGIN_NAME} PUBLIC cxx_std_20) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) + +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter + flutter_wrapper_plugin +) + +set_target_properties(${PLUGIN_NAME} PROPERTIES VS_PROJECT_IMPORT + "${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.CppWinRT/build/native/Microsoft.Windows.CppWinRT.props" +) + +target_link_libraries(${PLUGIN_NAME} PRIVATE + "${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.CppWinRT/build/native/Microsoft.Windows.CppWinRT.targets" +) + +set(flutter_tts_bundled_libraries "" PARENT_SCOPE) \ No newline at end of file diff --git a/third_party/flutter_tts/windows/flutter_tts_plugin.cpp b/third_party/flutter_tts/windows/flutter_tts_plugin.cpp new file mode 100644 index 00000000..6d59088a --- /dev/null +++ b/third_party/flutter_tts/windows/flutter_tts_plugin.cpp @@ -0,0 +1,659 @@ +#include "include/flutter_tts/flutter_tts_plugin.h" +// This must be included before many other Windows headers. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef std::unique_ptr> FlutterResult; +//typedef flutter::MethodResult* PFlutterResult; + +std::unique_ptr> methodChannel; + +#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_DESKTOP_APP) +#include +#include +#include +using namespace winrt; +using namespace Windows::Media::SpeechSynthesis; +using namespace Concurrency; +using namespace std::chrono_literals; +#include +#include +namespace { + class FlutterTtsPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + FlutterTtsPlugin(); + virtual ~FlutterTtsPlugin(); + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void speak(const std::string, FlutterResult); + void pause(); + void continuePlay(); + void stop(); + void setVolume(const double); + void setPitch(const double); + void setRate(const double); + void getVoices(flutter::EncodableList&); + void setVoice(const std::string, const std::string, FlutterResult&); + void getLanguages(flutter::EncodableList&); + void setLanguage(const std::string, FlutterResult&); + void addMplayer(); + winrt::Windows::Foundation::IAsyncAction asyncSpeak(const std::string); + bool speaking(); + bool paused(); + SpeechSynthesizer synth; + winrt::Windows::Media::Playback::MediaPlayer mPlayer; + bool isPaused; + bool isSpeaking; + bool awaitSpeakCompletion; + FlutterResult speakResult; + }; + + void FlutterTtsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + methodChannel = + std::make_unique>( + registrar->messenger(), "flutter_tts", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + + methodChannel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); + } + + void FlutterTtsPlugin::addMplayer() { + mPlayer = winrt::Windows::Media::Playback::MediaPlayer::MediaPlayer(); + auto mEndedToken = + mPlayer.MediaEnded([=](Windows::Media::Playback::MediaPlayer const& sender, + Windows::Foundation::IInspectable const& args) + { + methodChannel->InvokeMethod("speak.onComplete", NULL); + if (awaitSpeakCompletion) { + speakResult->Success(1); + } + isSpeaking = false; + }); + } + + bool FlutterTtsPlugin::speaking() { + return isSpeaking; + } + + bool FlutterTtsPlugin::paused() { + return isPaused; + } + + winrt::Windows::Foundation::IAsyncAction FlutterTtsPlugin::asyncSpeak(const std::string text) { + SpeechSynthesisStream speechStream{ + co_await synth.SynthesizeTextToStreamAsync(to_hstring(text)) + }; + winrt::param::hstring cType = L"Audio"; + winrt::Windows::Media::Core::MediaSource source = + winrt::Windows::Media::Core::MediaSource::CreateFromStream(speechStream, cType); + mPlayer.Source(source); + mPlayer.Play(); + } + + void FlutterTtsPlugin::speak(const std::string text, FlutterResult result) { + isSpeaking = true; + auto my_task{ asyncSpeak(text) }; + methodChannel->InvokeMethod("speak.onStart", NULL); + if (awaitSpeakCompletion) speakResult = std::move(result); + else result->Success(1); + }; + + void FlutterTtsPlugin::pause() { + mPlayer.Pause(); + isPaused = true; + methodChannel->InvokeMethod("speak.onPause", NULL); + } + + void FlutterTtsPlugin::continuePlay() { + mPlayer.Play(); + isPaused = false; + methodChannel->InvokeMethod("speak.onContinue", NULL); + } + + void FlutterTtsPlugin::stop() { + methodChannel->InvokeMethod("speak.onCancel", NULL); + if (awaitSpeakCompletion) { + speakResult->Success(1); + } + + mPlayer.Close(); + addMplayer(); + isSpeaking = false; + isPaused = false; + } + void FlutterTtsPlugin::setVolume(const double newVolume) { synth.Options().AudioVolume(newVolume); } + + void FlutterTtsPlugin::setPitch(const double newPitch) { synth.Options().AudioPitch(newPitch); } + + void FlutterTtsPlugin::setRate(const double newRate) { synth.Options().SpeakingRate(newRate + 0.5); } + + void FlutterTtsPlugin::getVoices(flutter::EncodableList& voices) { + auto synthVoices = synth.AllVoices(); + std::for_each(begin(synthVoices), end(synthVoices), [&voices](const VoiceInformation& voice) + { + flutter::EncodableMap voiceInfo; + voiceInfo[flutter::EncodableValue("locale")] = to_string(voice.Language()); + voiceInfo[flutter::EncodableValue("name")] = to_string(voice.DisplayName()); + // Convert VoiceGender to string + std::string gender; + switch (voice.Gender()) { + case VoiceGender::Male: + gender = "male"; + break; + case VoiceGender::Female: + gender = "female"; + break; + default: + gender = "unknown"; + break; + } + voiceInfo[flutter::EncodableValue("gender")] = gender; + // Identifier example "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens\MSTTS_V110_enUS_MarkM" + voiceInfo[flutter::EncodableValue("identifier")] = to_string(voice.Id()); + voices.push_back(flutter::EncodableMap(voiceInfo)); + }); + } + + void FlutterTtsPlugin::setVoice(const std::string voiceLanguage, const std::string voiceName, FlutterResult& result) { + bool found = false; + auto voices = synth.AllVoices(); + VoiceInformation newVoice = synth.Voice(); + std::for_each(begin(voices), end(voices), [&voiceLanguage, &voiceName, &found, &newVoice](const VoiceInformation& voice) + { + if (to_string(voice.Language()) == voiceLanguage && to_string(voice.DisplayName()) == voiceName) + { + newVoice = voice; + found = true; + } + }); + synth.Voice(newVoice); + if (found) result->Success(1); + else result->Success(0); + } + + void FlutterTtsPlugin::getLanguages(flutter::EncodableList& languages) { + auto synthVoices = synth.AllVoices(); + std::set languagesSet = {}; + std::for_each(begin(synthVoices), end(synthVoices), [&languagesSet](const VoiceInformation& voice) + { + languagesSet.insert(flutter::EncodableValue(to_string(voice.Language()))); + }); + std::for_each(begin(languagesSet), end(languagesSet), [&languages](const flutter::EncodableValue value) + { + languages.push_back(value); + }); + } + void FlutterTtsPlugin::setLanguage(const std::string voiceLanguage, FlutterResult& result) { + bool found = false; + auto voices = synth.AllVoices(); + VoiceInformation newVoice = synth.Voice(); + std::for_each(begin(voices), end(voices), [&voiceLanguage, &newVoice, &found](const VoiceInformation& voice) + { + if (to_string(voice.Language()) == voiceLanguage) newVoice = voice; + found = true; + }); + synth.Voice(newVoice); + if (found) result->Success(1); + else result->Success(0); + } + + + FlutterTtsPlugin::FlutterTtsPlugin() { + synth = SpeechSynthesizer(); + addMplayer(); + isPaused = false; + isSpeaking = false; + awaitSpeakCompletion = false; + speakResult = FlutterResult(); + } + + FlutterTtsPlugin::~FlutterTtsPlugin() { mPlayer.Close(); } + + void FlutterTtsPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + FlutterResult result) { + if (method_call.method_name().compare("getPlatformVersion") == 0) { + std::ostringstream version_stream; + version_stream << "Windows UWP"; + result->Success(flutter::EncodableValue(version_stream.str())); + } + +#else +#include +#include +#include +#include +#pragma warning(disable:4996) +#include +#pragma warning(default: 4996) +namespace { + + class FlutterTtsPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + FlutterTtsPlugin(); + virtual ~FlutterTtsPlugin(); + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + void speak(const std::string, FlutterResult); + void pause(); + void continuePlay(); + void stop(); + void setVolume(const double); + void setPitch(const double); + void setRate(const double); + void getVoices(flutter::EncodableList&); + void setVoice(const std::string, const std::string, FlutterResult&); + void getLanguages(flutter::EncodableList&); + void setLanguage(const std::string, FlutterResult&); + + ISpVoice* pVoice; + bool awaitSpeakCompletion = false; + bool isPaused; + double pitch; + bool speaking(); + bool paused(); + FlutterResult speakResult; + HANDLE addWaitHandle; + }; + + void FlutterTtsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + methodChannel = + std::make_unique>( + registrar->messenger(), "flutter_tts", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + methodChannel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); + } + + FlutterTtsPlugin::FlutterTtsPlugin() { + addWaitHandle = NULL; + isPaused = false; + speakResult = NULL; + pVoice = NULL; + HRESULT hr; + hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) + { + throw std::exception("TTS init failed"); + } + + hr = CoCreateInstance(CLSID_SpVoice, NULL, CLSCTX_ALL, IID_ISpVoice, (void**)&pVoice); + if (FAILED(hr)) + { + throw std::exception("TTS create instance failed"); + } + pitch = 0; + } + + FlutterTtsPlugin::~FlutterTtsPlugin() { + ::CoUninitialize(); + } + + void CALLBACK setResult(PVOID lpParam, BOOLEAN TimerOrWaitFired) + { + flutter::MethodResult* p = (flutter::MethodResult*) lpParam; + p->Success(1); + } + + void CALLBACK onCompletion(PVOID lpParam, BOOLEAN TimerOrWaitFired) + { + methodChannel->InvokeMethod("speak.onComplete", NULL); + } + + bool FlutterTtsPlugin::speaking() + { + SPVOICESTATUS status; + pVoice->GetStatus(&status, NULL); + if (status.dwRunningState == SPRS_IS_SPEAKING) return true; + return false; + } + bool FlutterTtsPlugin::paused() { return isPaused; } + + + void FlutterTtsPlugin::speak(const std::string text, FlutterResult result) { + HRESULT hr; + const std::string arg = "" + text; + + int wchars_num = MultiByteToWideChar(CP_UTF8, 0, arg.c_str(), -1, NULL, 0); + wchar_t* wstr = new wchar_t[wchars_num]; + MultiByteToWideChar(CP_UTF8, 0, arg.c_str(), -1, wstr, wchars_num); + hr = pVoice->Speak(wstr, 1, NULL); + delete[] wstr; + HANDLE speakCompletionHandle = pVoice->SpeakCompleteEvent(); + methodChannel->InvokeMethod("speak.onStart", NULL); + RegisterWaitForSingleObject(&addWaitHandle, speakCompletionHandle, (WAITORTIMERCALLBACK)&onCompletion, speakResult.get(), INFINITE, WT_EXECUTEONLYONCE); + if (awaitSpeakCompletion){ + speakResult = std::move(result); + RegisterWaitForSingleObject(&addWaitHandle, speakCompletionHandle, (WAITORTIMERCALLBACK)&setResult, speakResult.get(), INFINITE, WT_EXECUTEONLYONCE); + } + else result->Success(1); + } + void FlutterTtsPlugin::pause() + { + if (isPaused == false) + { + pVoice->Pause(); + isPaused = true; + } + methodChannel->InvokeMethod("speak.onPause", NULL); + } + void FlutterTtsPlugin::continuePlay() + { + isPaused = false; + pVoice->Resume(); + methodChannel->InvokeMethod("speak.onContinue", NULL); + } + void FlutterTtsPlugin::stop() + { + pVoice->Speak(L"", 2, NULL); + pVoice->Resume(); + isPaused = false; + methodChannel->InvokeMethod("speak.onCancel", NULL); + } + void FlutterTtsPlugin::setVolume(const double newVolume) + { + const USHORT volume = (short)(100 * newVolume); + pVoice->SetVolume(volume); + } + void FlutterTtsPlugin::setPitch(const double newPitch) {pitch = newPitch;} + void FlutterTtsPlugin::setRate(const double newRate) + { + const long speechRate = (long)((newRate - 0.5) * 15); + pVoice->SetRate(speechRate); + } + void FlutterTtsPlugin::getVoices(flutter::EncodableList& voices) { + HRESULT hr; + IEnumSpObjectTokens* cpEnum = NULL; + hr = SpEnumTokens(SPCAT_VOICES, NULL, NULL, &cpEnum); + if (FAILED(hr)) return; + + ULONG ulCount = 0; + // Get the number of voices. + hr = cpEnum->GetCount(&ulCount); + if (FAILED(hr)) return; + ISpObjectToken* cpVoiceToken = NULL; + while (ulCount--) + { + cpVoiceToken = NULL; + hr = cpEnum->Next(1, &cpVoiceToken, NULL); + if (FAILED(hr)) return; + CComPtr cpAttribKey; + hr = cpVoiceToken->OpenKey(L"Attributes", &cpAttribKey); + if (FAILED(hr)) return; + WCHAR* psz = NULL; + hr = cpAttribKey->GetStringValue(L"Language", &psz); + wchar_t locale[25]; + LCIDToLocaleName((LCID)std::strtol(CW2A(psz), NULL, 16), locale, 25, 0); + ::CoTaskMemFree(psz); + std::string language = CW2A(locale); + psz = NULL; + cpAttribKey->GetStringValue(L"Name", &psz); + std::string name = CW2A(psz); + ::CoTaskMemFree(psz); + flutter::EncodableMap voiceInfo; + voiceInfo[flutter::EncodableValue("locale")] = language; + voiceInfo[flutter::EncodableValue("name")] = name; + voices.push_back(flutter::EncodableMap(voiceInfo)); + cpVoiceToken->Release(); + } + } + void FlutterTtsPlugin::setVoice(const std::string voiceLanguage, const std::string voiceName, FlutterResult& result) { + HRESULT hr; + IEnumSpObjectTokens* cpEnum = NULL; + hr = SpEnumTokens(SPCAT_VOICES, NULL, NULL, &cpEnum); + if (FAILED(hr)) { result->Success(0); return; } + ULONG ulCount = 0; + hr = cpEnum->GetCount(&ulCount); + if (FAILED(hr)) { result->Success(0); return; } + ISpObjectToken* cpVoiceToken = NULL; + bool success = false; + while (ulCount--) + { + cpVoiceToken = NULL; + hr = cpEnum->Next(1, &cpVoiceToken, NULL); + if (FAILED(hr)) { result->Success(0); return; } + CComPtr cpAttribKey; + hr = cpVoiceToken->OpenKey(L"Attributes", &cpAttribKey); + if (FAILED(hr)) { result->Success(0); return; } + WCHAR* psz = NULL; + hr = cpAttribKey->GetStringValue(L"Name", &psz); + if (FAILED(hr)) { result->Success(0); return; } + std::string name = CW2A(psz); + ::CoTaskMemFree(psz); + psz = NULL; + hr = cpAttribKey->GetStringValue(L"Language", &psz); + wchar_t locale[25]; + LCIDToLocaleName((LCID)std::strtol(CW2A(psz), NULL, 16), locale, 25, 0); + ::CoTaskMemFree(psz); + std::string language = CW2A(locale); + if (name == voiceName && language == voiceLanguage) + { + pVoice->SetVoice(cpVoiceToken); + success = true; + } + cpVoiceToken->Release(); + } + result->Success(success ? 1 : 0); + } + void FlutterTtsPlugin::getLanguages(flutter::EncodableList& languages) + { + HRESULT hr; + IEnumSpObjectTokens* cpEnum = NULL; + hr = SpEnumTokens(SPCAT_VOICES, NULL, NULL, &cpEnum); + if (FAILED(hr)) return; + + ULONG ulCount = 0; + // Get the number of voices. + hr = cpEnum->GetCount(&ulCount); + if (FAILED(hr)) return; + ISpObjectToken* cpVoiceToken = NULL; + std::set languagesSet = {}; + while (ulCount--) + { + cpVoiceToken = NULL; + hr = cpEnum->Next(1, &cpVoiceToken, NULL); + if (FAILED(hr)) return; + CComPtr cpAttribKey; + hr = cpVoiceToken->OpenKey(L"Attributes", &cpAttribKey); + if (FAILED(hr)) return; + + WCHAR* psz = NULL; + hr = cpAttribKey->GetStringValue(L"Language", &psz); + wchar_t locale[25]; + LCIDToLocaleName((LCID)std::strtol(CW2A(psz), NULL, 16), locale, 25, 0); + std::string language = CW2A(locale); + languagesSet.insert(flutter::EncodableValue(language)); + ::CoTaskMemFree(psz); + cpVoiceToken->Release(); + } + std::for_each(begin(languagesSet), end(languagesSet), [&languages](const flutter::EncodableValue value) + { + languages.push_back(value); + }); + } + + void FlutterTtsPlugin::setLanguage(const std::string voiceLanguage, FlutterResult& result) { + HRESULT hr; + IEnumSpObjectTokens* cpEnum = NULL; + hr = SpEnumTokens(SPCAT_VOICES, NULL, NULL, &cpEnum); + if (FAILED(hr)) { result->Success(0); return; } + ULONG ulCount = 0; + hr = cpEnum->GetCount(&ulCount); + if (FAILED(hr)) { result->Success(0); return; } + ISpObjectToken* cpVoiceToken = NULL; + bool found = false; + while (ulCount--) + { + cpVoiceToken = NULL; + hr = cpEnum->Next(1, &cpVoiceToken, NULL); + if (FAILED(hr)) { result->Success(0); return; } + CComPtr cpAttribKey; + hr = cpVoiceToken->OpenKey(L"Attributes", &cpAttribKey); + if (FAILED(hr)) { result->Success(0); return; } + + WCHAR* psz = NULL; + hr = cpAttribKey->GetStringValue(L"Language", &psz); + wchar_t locale[25]; + LCIDToLocaleName((LCID)std::strtol(CW2A(psz), NULL, 16), locale, 25, 0); + std::string language = CW2A(locale); + if (language == voiceLanguage) + { + pVoice->SetVoice(cpVoiceToken); + found = true; + } + ::CoTaskMemFree(psz); + cpVoiceToken->Release(); + } + if (found) result->Success(1); + else result->Success(0); + } + + + void FlutterTtsPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + FlutterResult result) { + + if (method_call.method_name().compare("getPlatformVersion") == 0) { + std::ostringstream version_stream; + version_stream << "Windows "; + if (IsWindows10OrGreater()) { + version_stream << "10+"; + } + else if (IsWindows8OrGreater()) { + version_stream << "8"; + } + else if (IsWindows7OrGreater()) { + version_stream << "7"; + } + result->Success(flutter::EncodableValue(version_stream.str())); + } +#endif + else if (method_call.method_name().compare("awaitSpeakCompletion") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + awaitSpeakCompletion = std::get(arg); + result->Success(1); + } + else result->Success(0); + } + else if (method_call.method_name().compare("speak") == 0) { + if (isPaused) { continuePlay(); result->Success(1); return; } + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + if (!speaking()) { + const std::string text = std::get(arg); + speak(text, std::move(result)); + } + else result->Success(0); + } + else result->Success(0); + } + else if (method_call.method_name().compare("pause") == 0) { + FlutterTtsPlugin::pause(); + result->Success(1); + } + else if (method_call.method_name().compare("setLanguage") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + const std::string lang = std::get(arg); + setLanguage(lang, result); + } + else result->Success(0); + } + else if (method_call.method_name().compare("setVolume") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + const double newVolume = std::get(arg); + setVolume(newVolume); + result->Success(1); + } + else result->Success(0); + + } + else if (method_call.method_name().compare("setSpeechRate") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + const double newRate = std::get(arg); + setRate(newRate); + result->Success(1); + } + else result->Success(0); + + } + else if (method_call.method_name().compare("setPitch") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + const double newPitch = std::get(arg); + setPitch(newPitch); + result->Success(1); + } + else result->Success(0); + } + else if (method_call.method_name().compare("setVoice") == 0) { + const flutter::EncodableValue arg = method_call.arguments()[0]; + if (std::holds_alternative(arg)) { + const flutter::EncodableMap voiceInfo = std::get(arg); + std::string voiceLanguage = ""; + std::string voiceName = ""; + auto voiceLanguage_it = voiceInfo.find(flutter::EncodableValue("locale")); + if (voiceLanguage_it != voiceInfo.end()) voiceLanguage = std::get(voiceLanguage_it->second); + auto voiceName_it = voiceInfo.find(flutter::EncodableValue("name")); + if (voiceName_it != voiceInfo.end()) voiceName = std::get(voiceName_it->second); + setVoice(voiceLanguage, voiceName, result); + } + else result->Success(0); + } + else if (method_call.method_name().compare("stop") == 0) { + stop(); + result->Success(1); + } + else if (method_call.method_name().compare("getLanguages") == 0) { + flutter::EncodableList l; + getLanguages(l); + result->Success(l); + } + else if (method_call.method_name().compare("getVoices") == 0) { + flutter::EncodableList l; + getVoices(l); + result->Success(l); + } + else { + result->NotImplemented(); + } + } +} + +void FlutterTtsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + FlutterTtsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/flutter_tts/windows/include/flutter_tts/flutter_tts_plugin.h b/third_party/flutter_tts/windows/include/flutter_tts/flutter_tts_plugin.h new file mode 100644 index 00000000..8feadf91 --- /dev/null +++ b/third_party/flutter_tts/windows/include/flutter_tts/flutter_tts_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLUTTER_TTS_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLUTTER_TTS_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void FlutterTtsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLUTTER_TTS_PLUGIN_H_ diff --git a/third_party/flutter_web_auth_2/CHANGELOG.md b/third_party/flutter_web_auth_2/CHANGELOG.md new file mode 100644 index 00000000..285fcb61 --- /dev/null +++ b/third_party/flutter_web_auth_2/CHANGELOG.md @@ -0,0 +1,406 @@ +## 6.0.0-alpha.7 + +- 🐛 Handle missing `authUri`/`callbackScheme` gracefully (Thanks to [@md-shohan-ahmed](https://github.com/md-shohan-ahmed) in [#209](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/209), related to [#207](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/207)) + +## 5.1.0 + +- 🐛 Catch `SecurityException` when launching the auth tab (Thanks to [@hokeroid](https://github.com/hokeroid) in [#212](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/212), related to [#211](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/211)) +- 🐛 Handle missing `authUri`/`callbackScheme` gracefully (Thanks to [@md-shohan-ahmed](https://github.com/md-shohan-ahmed) in [#209](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/209), related to [#207](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/207)) + +## 6.0.0-alpha.6 + +- 🐛 Catch `SecurityException` when launching the auth tab (Thanks to [@hokeroid](https://github.com/hokeroid) in [#212](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/212), related to [#211](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/211)) + +## 6.0.0-alpha.5 + +- 🌹 Allow `window_to_front` versions `1.x` +- 🌹 Bump `melos` to `8.x` + +## 6.0.0-alpha.4 + +- 💥 Drop support for `web` versions `0.x` +- 🌹 Bump `window_to_front` to `0.1.0` + +## 6.0.0-alpha.3 + +- 🐛 Refactor error handling to use Swift `Error` type for improved compatibility (Thanks to [@Abhishek-Khanal](https://github.com/Abhishek-Khanal) in [#204](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/204)) + +## 6.0.0-alpha.2 + +- 💥 Bump Flutter and Dart SDK constraints to `3.44.0` and `3.12.0` stable, respectively +- 🌹 Enhancement: provide detailed error information in FlutterError responses for iOS and macOS (Thanks to [@Abhishek-Khanal](https://github.com/Abhishek-Khanal) in [#200](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/200)) +- 🌹 Migrate iOS plugin to UIScene lifecycle (Thanks to [@alihamza0173](https://github.com/alihamza0173) in [#201](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/201)) + +## 6.0.0-alpha.1 + +- 🌹 Bump `desktop_webview_window` to `0.3.0` + +## 5.0.3 + +- 🌹 Bump `desktop_webview_window` to `0.3.0` + +## 6.0.0-alpha.0 + +- 💥 Bump Flutter and Dart SDK constraints to `3.44.0` and `3.12.0` betas, respectively +- 💥 Regenerate entire project skeleton (also migrates to built-in Kotlin) +- 💥 Rename `cleanUpDanglingCalls` -> `clearAllDanglingCalls` (unification) + +## 5.0.2 + +- 🐛 Fix possible NPE on Android (Thanks to [@henry11996](https://github.com/henry11996) in [#196](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/196)) +- 🌹 Dummy implementation for `cleanUpDanglingCalls` on web to prevent unneeded crashes + +## 5.0.1 + +- 🐛 Fix crash on Android and implement proper feedback if no browser is installed (Thanks to [@Mino5531](https://github.com/Mino5531) in [#190](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/190)) + +## 5.0.0 + +*No changes - just the official version release!* + +## 5.0.0-alpha.7 + +- 🌹 Update Android intricacies +- 🐛 Revert to `CustomTabsIntent` when needed (Thanks to [@StevenSorial](https://github.com/StevenSorial) in [#181](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/181)) + +## 5.0.0-alpha.6 + +- 🌹 Fix ephemeral web views on Android; re-introduces `preferEphemeral` on Android for more iOS/Android parity (Thanks to [@ApofisXII](https://github.com/ApofisXII) in [#180](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/180), related to [#169](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/169)) + +## 5.0.0-alpha.5 + +- 🌹 Include more error details on iOS (Thanks to [@PawlikMichal25](https://github.com/PawlikMichal25) in [#179](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/179), related to [#178](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/178)) + +## 5.0.0-alpha.4 + +- 🌹 Update melos to regular `7.x` releases +- 🌹 Update linter to `6.x` +- 🌹 Fix a few Android warnings (Gradle `9.x` complaints) + +## 5.0.0-alpha.3 + +- 💥 Migrate to Swift Package Manager - see [here](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers) for more info (Thanks to [@lishaduck](https://github.com/lishaduck) in [#165](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/165)) + +## 5.0.0-alpha.2 + +- 💥 Even newer Android authentication approach (Thanks to [@titanous](https://github.com/titanous) in [#163](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/163)) +- 🌹 Add auto-close to old server method default landing page on Windows and Linux + +## 5.0.0-alpha.1 + +- 🌹 Added migration guide for `5.x` and updated migration guide for `4.x` + +## 5.0.0-alpha.0 + +- 💥 Update melos to `7.x` (requires SDK `>=3.5.0`) +- 💥 New Android authentication approach (Thanks to [@Mino5531](https://github.com/Mino5531) in [#156](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/156)) + +## 4.1.0 + +- 🌹 Add host and path comparator to webview implementation (related to [#146](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/146)) +- 🐛 Hopefully fix WASM compatibility + +## 4.0.2 + +- 🌹 Improve README (additions to migration guide and Google scopes) + +## 4.0.1 + +- 🌹 Update `flutter_lints` to `5.x` - should not really change anything! + +## 4.0.0 + +*No changes - just the official version release!* + +## 4.0.0-alpha.9 + +- 🐛 Fix Chrome always taking precedence on Android (Thanks to [@obrand69](https://github.com/obrand69) in [#125](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/125)) + +## 4.0.0-alpha.8 + +- 🌹 Allow also `web` versions `0.5.x` again (Thanks to [@Rexios80](https://github.com/Rexios80) in [#118](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/118)) +- 🌹 Improve documentation + +## 4.0.0-alpha.7 + +- 🎉 Enable `ASWebAuthenticationSession.Callback.https` support to fix Universal Links on iOS (Thanks to [@GabrielDamasceno](https://github.com/GabrielDamasceno) in [#117](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/117), related to [#65](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/65) and [#104](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/104)) +- 🌹 Update `web` to `1.x` + +## 4.0.0-alpha.6 + +- 🌹 Allow custom browser priority and rework Android code (Thanks to [@kecson](https://github.com/kecson) in [#111](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/111)) +- 🌹 Enable partial `ASWebAuthenticationSession.Callback` support (related to [#104](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/104)) + +## 4.0.0-alpha.5 + +- 💥 Remove Android embedding v1 support (in accordance with Flutter `3.22.0`) +- 💥 Increase Android min SDK to `21` (in accordance with Flutter `3.22.0`) +- 💥 Increase Android JVM version to `11` (in accordance with Flutter `3.22.0`) +- 🌹 Update AndroidX browser to `1.8.0` +- 🌹 Migrate Android and Web examples to newest standards + +## 4.0.0-alpha.4 + +- 🐛 Fix recursion errors in Flutter `3.22.0` +- 🌹 Update dependencies (`melos` and `flutter_lints`) + +## 3.1.2 + +- 🐛 Fix recursion errors in Flutter `3.22.0` +- 🌹 Update `melos` to `5.1.0` to have cleaner CI commands + +## 4.0.0-alpha.3 + +- 🐛 Fix `iframe` approach +- 🌹 Update `melos` to `5.1.0` to have cleaner CI commands + +## 4.0.0-alpha.2 + +- 🌹 Migrate to `web` package + +## 4.0.0-alpha.1 + +- 🌹 Fix lint for Flutter 3.19.x + +## 4.0.0-alpha.0 + +- 🎉 Add Webview approach for Windows and Linux (Thanks to [@Mino5531](https://github.com/Mino5531) in [#92](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/92)) + +## 3.1.1 + +- 🐛 Fix null-safe options (again) + +## 3.1.0 + +- 🎉 Implement `iframe` authentication approach (Thanks to [@rundfunk47](https://github.com/rundfunk47) in [#88](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/88)) +- 🌹 Fix lint for Flutter 3.16.x + +## 3.0.4 + +- 🐛 Fix null-safe options + +## 3.0.3 + +- 🌹 Fix Web implementation not being found properly (See [#82](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/82)) + +## 3.0.2 + +- 🌹 Fix deprecations in Gradle 8.x + +## 3.0.1 + +- 🌹 Better null-safe options + +## 3.0.0 + +- 💥 Release next major version, yay! +- 🐛 Fix Apple implementations + +## 3.0.0-alpha.6 + +- 🐛 Trying a few attempts to fix iOS implementations + +## 3.0.0-alpha.5 + +- 🌹 Fix iOS "screenshot" +- 🐛 Trying a few attempts to fix iOS and MacOS implementations + +## 3.0.0-alpha.4 + +- 🐛 Trying a few attempts to fix iOS and MacOS implementations + +## 3.0.0-alpha.3 + +- 🌹 Add more entries to the pubspec +- 🌹 Add reference to `android_intent_plus` + +## 3.0.0-alpha.2 + +- 🎉 Customizable landing page on Windows and Linux +- 🌹 Update some files from official Flutter template + +## 3.0.0-alpha.1 + +- 🐛 Update platform interface version + +## 3.0.0-alpha.0 + +- 💥 Bump Dart SDK constraints to `>=2.15.0` +- 💥 Huge refactor with more configuration possibilities +- 🎉 Add `melos` to project +- 🌹 Cleanup Android implementation + +## 2.2.1 + +- 🌹 Timeouts (5 minutes) on Web throw real errors now + +## 2.2.0 + +- 🎉 Add fallback for `window.opener` being `null` on Web (See [#58](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/58)) **(See enhanced/better setup instructions for Web in the README!)** +- 🌹 Add delay to example +- 🌹 Bring back support for older AGP versions +- 🐛 Fix `compileOptions` on Android (See [#59](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/59)) + +## 2.1.5 + +- 🌹 Fix example support for Dart `3.x` +- 🐛 Implement protocol handler to supply `ASPresentationAnchor` (See [#51](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/51)) + +## 2.1.4 + +- 🌹 Fix support for Dart `3.x` +- 🌹 Support AGP `8.x` namespace declaration +- 🌹 Fix web example to work "properly" +- 🐛 Update `flutter_web_auth_2_platform_interface` to require `2.1.4` at least + +## 2.1.3 + +- 🌹 Add `contextArgs` for web implementations (See [#40](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/40)) + +## 2.1.2 + +- 🐛 Downgrade Kotlin to fix some compatibility issues + +## 2.1.1 + +- 🌹 Fix formatting + +## 2.1.0 + +- 🎉 Add support for Linux ([#31](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/31)) +- 🌹 Get rid of dependency on `win32` and `ffi` by using `window_to_front` instead +- 🌹 Update linter rules +- 🌹 Update Android dependencies + +## 2.0.4 + +- 🐛 Partially fix ([#135](https://github.com/LinusU/flutter_web_auth/issues/135)) +- 🌹 Support `win32` versions `2.7.0` until `4.x` +- 🌹 Fix typos in README + +## 2.0.3 + +- 🌹 Add Universal link support ([#26](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/26)) +- 🌹 Some small documentation changes + +## 2.0.2 + +- 🐛 Fix windows callback verification ([#22](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/22)) +- 🐛 Force-update platform interface ([#21](https://github.com/ThexXTURBOXx/flutter_web_auth_2/pull/21)) + +## 2.0.1 + +- 🌹 Merge upstream changes (most notably troubleshooting documentation) +- 🌹 Added `redirectOriginOverride` for web implementations (By [Rexios80](https://github.com/Rexios80)) +- 🌹 Fix some documentation and changelogs + +## 2.0.0 + +- 💥 Full two-package federated plugin refactor + +## 1.1.2 + +- 🌹 Support `win32` versions `2.7.0` until `3.x` + +## 1.1.1 + +- 🐛 Fix Windows support and support for future platforms +- 🐛 Fix example on Windows +- 🌹 `127.0.0.1` is now also a supported callback URL host on Windows +- 🌹 Cleaned up platform implementations + +## 1.1.0 + +- 🌹 Tested `flutter_web_auth_2` with Flutter `3.3.0`, seems to work! +- 🌹 Update `win32` to `3.0.0` (relevant only for Windows users) +- 🌹 Add `android:exported` tag to documentation *before* users start to complain +- 🌹 Overhauled example layout + +## 1.0.1 + +- 🐛 Remove path dependency +- 🌹 Add migration guide README +- 🌹 Add more documentation + +## 1.0.0 + +- 💥 Old project, new maintainers! Due to the lack of updates in the main project, we forked the project and will update it as time passes! +- 💥 Update to Flutter 3 ([#118](https://github.com/LinusU/flutter_web_auth/pull/118)) +- 💥 Federated plugin refactor ([#98](https://github.com/LinusU/flutter_web_auth/pull/98)) +- 💥 Windows support (By [Jon-Salmon](https://github.com/Jon-Salmon/flutter_web_auth/tree/windows-implementation)) +- 🎉 Add support for ephemeral sessions on MacOS ([#112](https://github.com/LinusU/flutter_web_auth/pull/112)) + +## 0.4.1 + +- 🎉 Add support for Flutter "add to app" ([#106](https://github.com/LinusU/flutter_web_auth/pull/106)) + +## 0.4.0 + +- 💥 Upgrade to Android V2 embedding ([#87](https://github.com/LinusU/flutter_web_auth/pull/87)) + + Migration guide: + + Make sure that you are running a recent version of Flutter before upgrading. + +## 0.3.2 + +- 🎉 Add Web support ([#77](https://github.com/LinusU/flutter_web_auth/pull/77)) + +## 0.3.1 + +- 🎉 Add support for Android Plugin API v2 ([#67](https://github.com/LinusU/flutter_web_auth/pull/67)) +- 🎉 Add support for ephemeral sessions ([#64](https://github.com/LinusU/flutter_web_auth/pull/64)) +- 🌹 Avoid deprecated RaisedButton in example ([#75](https://github.com/LinusU/flutter_web_auth/pull/75)) +- 🌹 Cleanup metadata + +## 0.3.0 + +- 💥 Add null safety support ([#60](https://github.com/LinusU/flutter_web_auth/pull/60)) + + Migration guide: + + This version drops support for Flutter 1.x, please upgrade to Flutter 2 for continued support. + +## 0.2.4 + +- 🐛 Fix building on iOS ([#36](https://github.com/LinusU/flutter_web_auth/pull/36)) + +## 0.2.3 + +- 🐛 Remove NoHistory flag ([#33](https://github.com/LinusU/flutter_web_auth/pull/33)) +- 🐛 Fix building on iOS 8, 9, and 10 ([#29](https://github.com/LinusU/flutter_web_auth/pull/29)) +- 🐛 Always terminate 'authenticate' callbacks on Android ([#28](https://github.com/LinusU/flutter_web_auth/pull/28)) + +## 0.2.2 + +- 🐛 Fix propagation of "CANCELED" error on iOS ([#31](https://github.com/LinusU/flutter_web_auth/pull/31)) + +## 0.2.1 + +- 🐛 Fix AndroidX build issues ([#27](https://github.com/LinusU/flutter_web_auth/pull/27)) + +## 0.2.0 + +- 💥 Add macOS support ([#20](https://github.com/LinusU/flutter_web_auth/pull/20)) + + Migration guide: + + This version drops support for Flutter 1.9 and older, please upgrade to Flutter 1.12 for continued support. + +## 0.1.3 + +- 🎉 Update the kotlin plugin version to 1.3.61 + +## 0.1.2 + +- 🎉 Add support for iOS 13 + +## 0.1.1 + +- 🐛 Add swift_version to the Podspec +- 🐛 Update Gradle and Kotlin versions +- 🐛 Add missing link in readme + +## 0.1.0 + +- 🎉 Add initial implementation diff --git a/third_party/flutter_web_auth_2/LICENSE b/third_party/flutter_web_auth_2/LICENSE new file mode 100644 index 00000000..02642e58 --- /dev/null +++ b/third_party/flutter_web_auth_2/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2019 Linus Unnebäck + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/flutter_web_auth_2/README.md b/third_party/flutter_web_auth_2/README.md new file mode 100644 index 00000000..64031b20 --- /dev/null +++ b/third_party/flutter_web_auth_2/README.md @@ -0,0 +1,396 @@ +# Web Auth 2 for Flutter + +**This project is a continuation of [flutter_web_auth](https://github.com/LinusU/flutter_web_auth) by Linus Unnebäck with many new features and bug fixes.** + +[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) + +A Flutter plugin for authenticating a user with a web service, even if the web service is run by a third party. Most commonly used with OAuth2, but can be used with any web flow that can redirect to a custom scheme. + +In the background, this plugin uses [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) on iOS 12+ and macOS 10.15+, [`SFAuthenticationSession`](https://developer.apple.com/documentation/safariservices/sfauthenticationsession) on iOS 11, [Chrome Auth Tab](https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab) on Android and opens a new window on Web. You can build it with iOS 8+, but it is currently only supported by iOS 11 or higher. + + + +## Usage + +Add the following snippet to your `pubspec.yaml` and follow the [Setup guide](#setup): + +```yaml +dependencies: + flutter_web_auth_2: ^5.0.0 +``` + +To authenticate against your own custom site: + +```dart +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + +// Present the dialog to the user +final result = await FlutterWebAuth2.authenticate(url: "https://my-custom-app.com/connect", callbackUrlScheme: "my-custom-app"); + +// Extract token from resulting url +final token = Uri.parse(result).queryParameters['token']; +``` + +To authenticate the user using Google's OAuth2: + +```dart +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + +import 'dart:convert' show jsonDecode; +import 'package:http/http.dart' as http; + +// App specific variables +final googleClientId = 'XXXXXXXXXXXX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com'; +final callbackUrlScheme = 'com.googleusercontent.apps.XXXXXXXXXXXX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + +// Construct the url +final url = Uri.https('accounts.google.com', '/o/oauth2/v2/auth', { + 'response_type': 'code', + 'client_id': googleClientId, + 'redirect_uri': '$callbackUrlScheme:/', + 'scope': 'email', +}); + +// Present the dialog to the user +final result = await FlutterWebAuth2.authenticate(url: url.toString(), callbackUrlScheme: callbackUrlScheme); + +// Extract code from resulting url +final code = Uri.parse(result).queryParameters['code']; + +// Construct an Uri to Google's oauth2 endpoint +final url = Uri.https('www.googleapis.com', 'oauth2/v4/token'); + +// Use this code to get an access token +final response = await http.post(url, body: { + 'client_id': googleClientId, + 'redirect_uri': '$callbackUrlScheme:/', + 'grant_type': 'authorization_code', + 'code': code, +}); + +// Get the access token from the response +final accessToken = jsonDecode(response.body)['access_token'] as String; +``` + +**Note (Google multiple scopes):** To use multiple scopes with Google, you need to encode them as a single string, separated by spaces (`%20`). For example, `scope: 'email https://www.googleapis.com/auth/userinfo.profile'`. Here is [a list of all supported scopes](https://developers.google.com/identity/protocols/oauth2/scopes). + +**Note (ephemeral auth):** Due to a [known Chrome bug](https://issuetracker.google.com/issues/444173718), when setting `preferEphemeral: true` on Android with Chrome < 141, the package will launch an auth session with older implementation to avoid crashes. + +## Migration + +### Upgrading to `5.x` + +The following constraints have been added in `5.0.0`: +- The existing Android approach has been replaced with a new (improved) one (no migration necessary; is automatically used) +- Android now uses Auth Tab: `httpsHost` and `httpsPath` are now required on Android if you are using a `https` in `callbackUrlScheme` +- On Android, issues with `taskAffinity`s have been fixed and it is strongly advised to set `android:taskAffinity=""` for all exported `Activity`s (this usually includes your `MainActivity` and `flutter_web_auth_2`'s `CallbackActivity`) +- Dart SDK `>=3.5.0` is now required (due to migration to melos `7.x`) +- If you want ephemeral authentication on Android, use `preferEphemeral` instead of `ephemeralIntentFlags` as `intentFlags` have other influences now + +### Upgrading to `4.x` + +Generally, the following constraints have been added in version `4.0.0`: +- Android embedding v1 support has been removed (it was deprecated already long ago) +- Android min SDK and JVM version has been increased to 21 and 11, respectively (in accordance with + Flutter `3.22.0`) +- Migrated away from deprecated iOS and macOS functions (now iOS 17.4 and macOS 14.4 are required!) + +Version `4.0.0` also introduced a new approach for Linux and Windows to authenticate users - using +Webview APIs. Hence, you only need to change your code if you are targeting Linux or Windows. +If you are fine with still using the old version, here is what you need to change: +- Pass `useWebview: false` into the options of your call to `authenticate`, like so: + ```dart + final result = await FlutterWebAuth2.authenticate( + url: url, + callbackUrlScheme: 'foobar', + options: const FlutterWebAuth2Options(useWebview: false), + ); + ``` + +If you want to use the new approach (**default behaviour!**), you need to do a bit more: +- Follow the "Getting started" guide of + [desktop_webview_window](https://pub.dev/packages/desktop_webview_window#getting-started) +- Make sure that your users know about the new requirements, as described + [here](https://pub.dev/packages/desktop_webview_window) + +Last, but not least, version `4.0.0` also added *full* support for Universal links on iOS and macOS! +In order to do this, two additional options have been added (which are *required* if +`callbackUrlScheme` is `https`!): +- `httpsHost` is the host of the callback URL +- `httpsPath` is the path of the callback URL + +### Upgrading to `3.x` + +Version `3.0.0` featured a huge refactor which made it possible to maintain even more configuration +possibilities. Even platform-specific ones! If you want to upgrade, you need to do the following: +- Follow the [Setup] within this README again for your platform and do it from scratch (there might + be changes!) +- Dart SDK constraints have been updated to `>=2.15.0`. +- The configuration parameters have been removed from the `authenticate` function. Now, you have to + pass a `FlutterWebAuth2Options` object with those options in it instead: + - `contextArgs`: This is now called `windowName` within `FlutterWebAuth2Options`. + - `redirectOriginOverride`: This is now called `debugOrigin` within `FlutterWebAuth2Options`. + - `preferEphemeral`: This has been split into the two named parameters `preferEphemeral` (for + iOS and MacOS) and `intentFlags` (for Android) within `FlutterWebAuth2Options`. The former works + exactly the same. However, if you want the old behaviour using `preferEphemeral` on Android, use + the `ephemeralIntentFlags` constant as value for `intentFlags`. **(THIS WAS REVERTED IN 5.x)** + +### Upgrading from `flutter_web_auth` + +If you used `flutter_web_auth` correctly (and without extra hackage) before, it should be sufficient to replace the following strings *everywhere* (yes, also in `AndroidManifest.xml` for example): +- `FlutterWebAuth` -> `FlutterWebAuth2` +- `flutter_web_auth` -> `flutter_web_auth_2` + +If you are using versions `>= 3.0.0`, you also need to follow the migration guide(s) above! + +If you are still unsure or something is not working as well as before, please [open a new issue](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/new/choose). + +### Built-in KGP/AGP 9+ support + +If you need to use built-in Kotlin Gradle plugin or Android Gradle Plugin 9+, use versions `6.x`. + +## Setup + +Setup is the same as for any Flutter plugin, with the following caveats: + +### Android + +In order to capture the callback url, the following `activity` needs to be added to your `AndroidManifest.xml`. Be sure to replace `YOUR_CALLBACK_URL_SCHEME_HERE` with your actual callback url scheme. + +```xml + + + + + + + + + + + + + + +``` + +If you are using `http` or `https` as your callback scheme, you also need to specify a host etc. +See [c:geo](https://github.com/cgeo/cgeo/blob/d7ab67629ac4798adaae194e563afe7df134fcd0/main/AndroidManifest.xml#L164) as an example for this. + +### iOS + +For "normal" authentication, just use this library as usual; there is nothing special to do! + +To authenticate using [Universal Links](https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html) +on iOS, use `https` as the provided `callbackUrlScheme`: + +```dart +final result = await FlutterWebAuth2.authenticate(url: "https://my-custom-app.com/connect", callbackUrlScheme: "https"); +``` + +### Web + +On the Web platform, an endpoint must be created that captures the callback URL and sends it to the application using the JavaScript `postMessage()` method. In the `./web` folder of the project, create an HTML file named, e.g. `auth.html` with content: + +```html + +Authentication complete +

Authentication is complete. If this does not happen automatically, please close the window.

+ +``` + +This HTML file is designed to handle both traditional `window`-based and `iframe`-based authentication flows. The JavaScript code checks the context and sends the authentication response accordingly. + +The redirect URL passed to the authentication service must be the same as the URL the application is running on (schema, host, port if necessary) and the path must point to the generated HTML file, in this case `/auth.html`. The `callbackUrlScheme` parameter of the `authenticate()` method does not take this into account, so it is possible to use a schema for native platforms in the code. + +For the Sign in with Apple in web_message response mode, postMessage from https://appleid.apple.com is also captured, and the authorisation object is returned as a URL fragment encoded as a query string (for compatibility with other providers). + +Additional parameters for the URL open call can be passed in the `authenticate` function using the `windowName` parameter from the options. The `silentAuth` parameter can be used to enable silent authentication within a hidden `iframe`, rather than opening a new window or tab. This is particularly useful for scenarios where a full-page redirect is not desirable. Setting this parameter to `true` allows for a seamless user experience by performing authentication in the background, making it ideal for token refreshes or maintaining user sessions without requiring explicit interaction from the user. + +### Windows and Linux + +When using `useWebview: false`, there is a limitation that the callback URL scheme must start with `http://localhost:{port}`. + +When specifying `useWebview: true` (which is the default behaviour), you need to make sure to follow [desktop_webview_window's guide](https://pub.dev/packages/desktop_webview_window). +Also be aware that your users might need to install a Webview API (which is preinstalled on Windows 11 and some Windows 10 and Linux installations). +For details, see also desktop_webview_window's guide above. + +## Troubleshooting + +When you use this package for the first time, you may experience some problems. These are some of the most common solutions: + +### General troubleshooting steps + +1. Stop the application if it is running. +2. Run the following commands: + - `flutter clean` + - `flutter pub upgrade` +3. Rerun the application after executing the above commands. Sometimes, they work wonders! + +### Troubleshooting `callbackUrlScheme` + +- `callbackUrlScheme` must be a valid schema string or else this library won't work +- A valid RFC 3986 URL scheme must consist of "a letter and followed by any combination of letters, digits, plus "`+`", period "`.`", or hyphen "`-`" +- scheme = `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` +- This means you can not use underscore "`_`", space "` `" or uppercase "`ABCDEF...`". It must also not start with a number. See [RFC3986#page-17](https://www.rfc-editor.org/rfc/rfc3986#page-17) +- Examples of VALID `callbackUrlScheme`s are `callback-scheme`, `another.scheme`, `examplescheme` +- Examples of INVALID `callbackUrlScheme`s are `callback_scheme`,`1another.scheme`, `exampleScheme` + +### Troubleshooting Flutter App + +- You have to tell the `FlutterWebAuth2.authenticate` function what your `callbackUrlScheme` is. +- Example: If your `callbackUrlScheme` is `valid-callback-scheme`, your dart code will look like + + ```dart + import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + + // Present the dialog to the user + final result = await FlutterWebAuth2.authenticate(url: "https://my-custom-app.com/connect", callbackUrlScheme: "valid-callback-scheme"); + ``` + +### Troubleshooting Android + +- You will need to update your `AndroidManifest.xml` to include the `com.linusu.flutter_web_auth_2.CallbackActivity` activity, something like + + ```xml + + + + + + + + + + + + + + + + ``` + +- Example of a valid `AndroidManifest.xml` with VALID `callbackUrlScheme`. In the example below our `callbackUrlScheme` is `valid-callback-scheme`. + + ```xml + + + + + + + + + + + + + + ``` + +- If you are targeting S+ (SDK version 31 and above) you need to provide an explicit value for `android:exported`. If you followed earlier installation instructions, this was not included. Make sure that you add `android:exported="true"` to the `com.linusu.flutter_web_auth.CallbackActivity` activity in your `AndroidManifest.xml` file. + + ```diff + - + + + ``` + +- If you want to have a callback URL with `http` or `https` scheme, you also need to specify a host etc. + See [c:geo](https://github.com/cgeo/cgeo/blob/d7ab67629ac4798adaae194e563afe7df134fcd0/main/AndroidManifest.xml#L164) as an example for this. +- In older versions of this package, there was a known problem with task affinities and launch modes (see [#113](https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/113)). In order to ensure that `flutter_web_auth_2` works correctly on these versions (prior to `5.0.0-alpha.2`), set `android:launchMode="singleTop"` and remove any `android:taskAffinity` entries. This configuration is guaranteed to work. In newer versions, this should not cause problems! + +### Troubleshooting OAuth redirects + +- Your OAuth Provider must redirect to the valid `callbackUrlScheme` + `://`. This mean if your `callbackUrlScheme` is `validscheme`, your OAuth Provider must redirect to `validscheme://` +- Example with `PHP`: + ```php + Go Back to App + ``` + +### Troubleshooting passing data to app + +- You can pass data back to your app by adding GET query parameters. This is done by adding a `name=value` type of data after your `callbackUrlScheme` + `://` + `?` +- Example to pass `access-token` to your app: + + ```text + my-callback-schema://?access-token=jdu9292s + ``` + +- You can pass multiple dates by concatenating them with `&`: + + ```text + my-callback-schema://?data1=value1&data2=value2 + ``` + +- Example to pass `access-token` and `user_id` to your app: + + ```text + my-callback-schema://?access-token=jdu9292s&user_id=23 + ``` + +- You can get the data in your app through `Uri.parse(result).queryParameters`: + + ```dart + // Present the dialog to the user + final result = await FlutterWebAuth2.authenticate(url: "https://my-custom-app.com/connect", callbackUrlScheme: "valid-callback-scheme"); + // Extract token from resulting url + String accessToken = Uri.parse(result).queryParameters['access-token']; + String userId = Uri.parse(result).queryParameters['user_id']; + ``` + +### Cannot open keyboard on iOS + +This seems to be a bug in `ASWebAuthenticationSession` and no workarounds have been found yet. Please see issue [#120](https://github.com/LinusU/flutter_web_auth/issues/120) for more info. + +### Error on macOS if Chrome is default browser + +This seems to be a bug in `ASWebAuthenticationSession` and no workarounds have been found yet. Please see issue [#136](https://github.com/LinusU/flutter_web_auth/issues/136) for more info. diff --git a/third_party/flutter_web_auth_2/android/build.gradle b/third_party/flutter_web_auth_2/android/build.gradle new file mode 100644 index 00000000..380bf8ed --- /dev/null +++ b/third_party/flutter_web_auth_2/android/build.gradle @@ -0,0 +1,59 @@ +group = 'com.linusu.flutter_web_auth_2' +version = '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '2.1.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdk = 36 + + if (project.android.hasProperty("namespace")) { + namespace = 'com.linusu.flutter_web_auth_2' + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation 'androidx.browser:browser:1.9.0' + implementation 'androidx.activity:activity-ktx:1.10.1' + } +} + +// Halkyon's patch (4 Nov 2026): Kotlin built into the Android Gradle plugin +// (AGP 9, android.builtInKotlin=true) rather than the Kotlin Gradle plugin +// applied here, as Flutter's guide for plugin authors has it. +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} diff --git a/third_party/flutter_web_auth_2/android/gradle.properties b/third_party/flutter_web_auth_2/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.jar b/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.properties b/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..d4081da4 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/flutter_web_auth_2/android/gradlew b/third_party/flutter_web_auth_2/android/gradlew new file mode 100644 index 00000000..23d15a93 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/third_party/flutter_web_auth_2/android/gradlew.bat b/third_party/flutter_web_auth_2/android/gradlew.bat new file mode 100755 index 00000000..5eed7ee8 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/third_party/flutter_web_auth_2/android/settings.gradle b/third_party/flutter_web_auth_2/android/settings.gradle new file mode 100644 index 00000000..15aa3ff1 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_web_auth_2' diff --git a/third_party/flutter_web_auth_2/android/src/main/AndroidManifest.xml b/third_party/flutter_web_auth_2/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..54f16c08 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/AuthenticationManagementActivity.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/AuthenticationManagementActivity.kt new file mode 100644 index 00000000..6c52cf93 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/AuthenticationManagementActivity.kt @@ -0,0 +1,248 @@ +package com.linusu.flutter_web_auth_2 + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.auth.AuthTabIntent.AuthResult +import androidx.browser.customtabs.CustomTabsIntent + +@SuppressLint("UnsafeOptInUsageError", "UnsafeOptInUsageWarning") +class AuthenticationManagementActivity : ComponentActivity() { + companion object { + const val KEY_AUTH_STARTED: String = "authStarted" + const val KEY_AUTH_URI: String = "authUri" + const val KEY_AUTH_OPTION_INTENT_FLAGS: String = "authOptionsIntentFlags" + const val KEY_AUTH_OPTION_TARGET_PACKAGE: String = "authOptionsTargetPackage" + const val KEY_AUTH_OPTION_PREFER_EPHEMERAL: String = "authOptionsPreferEphemeral" + const val KEY_AUTH_CALLBACK_SCHEME: String = "authCallbackScheme" + const val KEY_AUTH_CALLBACK_HOST: String = "authCallbackHost" + const val KEY_AUTH_CALLBACK_PATH: String = "authCallbackPath" + + fun createResponseHandlingIntent(context: Context): Intent { + val intent = Intent(context, AuthenticationManagementActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + return intent + } + } + + private var authStarted: Boolean = false + private var hasValidState: Boolean = false + private lateinit var authenticationUri: Uri + private var intentFlags: Int = 0 + private var targetPackage: String? = null + private var preferEphemeral: Boolean = false + private lateinit var callbackScheme: String + private var callbackHost: String? = null + private var callbackPath: String? = null + + private lateinit var authLauncher: ActivityResultLauncher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Register the activity result launcher + authLauncher = AuthTabIntent.registerActivityResultLauncher(this, this::handleAuthResult) + + if (savedInstanceState == null) { + extractState(intent.extras) + } else { + extractState(savedInstanceState) + } + } + + private fun handleAuthResult(result: AuthResult) { + val callback = FlutterWebAuth2Plugin.callbacks[callbackScheme] + if (callback == null) { + finish() + return + } + + when (result.resultCode) { + AuthTabIntent.RESULT_OK -> { + val uri = result.resultUri + if (uri != null) { + callback.success(uri.toString()) + } else { + callback.error("FAILED", "Authentication returned no URI", null) + } + } + + AuthTabIntent.RESULT_CANCELED -> { + callback.error("CANCELED", "User canceled authentication", null) + } + + else -> { + callback.error("FAILED", "Authentication failed with code: ${result.resultCode}", null) + } + } + + FlutterWebAuth2Plugin.callbacks.remove(callbackScheme) + finish() + } + + override fun onResume() { + super.onResume() + + // If we couldn't restore a valid authentication state (e.g. the app process was + // killed while the user was completing authentication in an external browser), + // extractState() has already finished this activity. Bail out here instead of + // touching the not-yet-initialized fields below. + if (!hasValidState) { + return + } + + if (!authStarted) { + + val intentBuilder = if (shouldUseAuthTabs()) { + Log.d(LOG_TAG, "Using AuthTabIntent") + AuthTabBuilderWrapper(AuthTabIntent.Builder()) + } else { + Log.d(LOG_TAG, "Using CustomTabsIntent") + CtBuilderWrapper(CustomTabsIntent.Builder()) + } + + // Set ephemeral browsing if requested and supported + if (preferEphemeral) { + try { + intentBuilder.setEphemeralBrowsingEnabled(true) + Log.d(LOG_TAG, "Ephemeral browsing enabled") + } catch (e: Exception) { + Log.w(LOG_TAG, "Failed to enable ephemeral browsing: ${e.message}") + } + } + + val intent = intentBuilder.build() + + intent.intent.addFlags(intentFlags) + if (targetPackage != null) { + intent.intent.setPackage(targetPackage) + } + + try { + if (callbackScheme == "https" && callbackHost != null && callbackPath != null) { + Log.d(LOG_TAG, "Using https host and path: $callbackHost, $callbackPath") + intent.launch(this, authLauncher, authenticationUri, callbackHost!!, callbackPath!!) + } else { + Log.d(LOG_TAG, "Using custom scheme: $callbackScheme") + intent.launch(this, authLauncher, authenticationUri, callbackScheme) + } + } catch (e: android.content.ActivityNotFoundException){ + Log.e(LOG_TAG, "Failed to start authentication. No browser available (Activity not found)") + val callback = FlutterWebAuth2Plugin.callbacks[callbackScheme] + callback?.error("NO_BROWSER", "No valid browser available for authentication.", e.message) + FlutterWebAuth2Plugin.callbacks.remove(callbackScheme) + finish() + } catch (e: SecurityException) { + // Some apps register themselves as browsers but do not export their + // intent-handling activity; launching the tab then throws instead of + // ActivityNotFoundException. Fail the attempt like the no-browser case + // rather than crashing the host app. + Log.e(LOG_TAG, "Failed to start authentication. Most likely, the selected browser activity is not exported (SecurityException)") + val callback = FlutterWebAuth2Plugin.callbacks[callbackScheme] + callback?.error("SECURITY_EXCEPTION", "Selected browser cannot be accessed for authentication.", e.message) + FlutterWebAuth2Plugin.callbacks.remove(callbackScheme) + finish() + } + + authStarted = true + return + } + /* If the authentication was already started and we've returned here, the user either + * completed or cancelled authentication. + * Either way we want to return to our original flutter activity, so just finish here + */ + finish() + } + + fun shouldUseAuthTabs(): Boolean { + + if (!preferEphemeral || targetPackage == null) return true + val packageMajorVersion = getInstalledVersion(targetPackage!!)?.substringBefore(".")?.toIntOrNull() ?: 0 + Log.d(LOG_TAG, "Chosen package: $targetPackage with version: $packageMajorVersion") + + val chromePackages = setOf( + PackageNames.CHROME_STABLE, + PackageNames.CHROME_BETA, + PackageNames.CHROME_DEV, + ) + + if (chromePackages.contains(targetPackage)) { + return packageMajorVersion >= 141 + } else if (targetPackage == PackageNames.MICROSOFT_EDGE) { + return packageMajorVersion >= 141 + } else if (targetPackage == PackageNames.SAMSUNG_INTERNET) { + return packageMajorVersion >= 28 + } else if (targetPackage == PackageNames.FIREFOX) { + return packageMajorVersion >= 143 + } + + return true + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(KEY_AUTH_STARTED, authStarted) + outState.putParcelable(KEY_AUTH_URI, authenticationUri) + outState.putInt(KEY_AUTH_OPTION_INTENT_FLAGS, intentFlags) + outState.putString(KEY_AUTH_OPTION_TARGET_PACKAGE, targetPackage) + outState.putBoolean(KEY_AUTH_OPTION_PREFER_EPHEMERAL, preferEphemeral) + outState.putString(KEY_AUTH_CALLBACK_SCHEME, callbackScheme) + outState.putString(KEY_AUTH_CALLBACK_HOST, callbackHost) + outState.putString(KEY_AUTH_CALLBACK_PATH, callbackPath) + } + + private fun extractState(state: Bundle?) { + if (state == null) { + finish() + return + } + + val scheme = state.getString(KEY_AUTH_CALLBACK_SCHEME) + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + state.getParcelable(KEY_AUTH_URI, Uri::class.java) + } else { + @Suppress("deprecation") + state.getParcelable(KEY_AUTH_URI) + } + + if (uri == null || scheme == null) { + // This can happen when Android recreates this activity from an incomplete + // saved-state Bundle after the app's process was killed (e.g. low memory) + // while the user was completing authentication in an external browser. + // Finish gracefully instead of crashing the host app, and resolve any + // still-pending callback with an error so callers don't hang forever. + Log.e( + LOG_TAG, + "Unable to restore authentication state (missing authUri and/or " + + "callbackScheme), likely due to process death. Finishing gracefully" + ) + if (scheme != null) { + FlutterWebAuth2Plugin.callbacks[scheme]?.error( + "CANNOT_RESTORE", + "Authentication session was lost, likely because the app was killed in the background. Please try again.", + null + ) + FlutterWebAuth2Plugin.callbacks.remove(scheme) + } + finish() + return + } + + authStarted = state.getBoolean(KEY_AUTH_STARTED, false) + authenticationUri = uri + intentFlags = state.getInt(KEY_AUTH_OPTION_INTENT_FLAGS, 0) + targetPackage = state.getString(KEY_AUTH_OPTION_TARGET_PACKAGE) + preferEphemeral = state.getBoolean(KEY_AUTH_OPTION_PREFER_EPHEMERAL, false) + callbackScheme = scheme + callbackHost = state.getString(KEY_AUTH_CALLBACK_HOST) + callbackPath = state.getString(KEY_AUTH_CALLBACK_PATH) + hasValidState = true + } +} diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/CallbackActivity.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/CallbackActivity.kt new file mode 100644 index 00000000..520d17de --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/CallbackActivity.kt @@ -0,0 +1,48 @@ +package com.linusu.flutter_web_auth_2 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle + +class CallbackActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val url = intent?.data ?: fixAutoVerifyNotWorks(intent) + val scheme = url?.scheme + + if (scheme != null) { + FlutterWebAuth2Plugin.callbacks.remove(scheme)?.success(url.toString()) + } + startActivity(AuthenticationManagementActivity.createResponseHandlingIntent(this)) + finish() + } + + + /** Fix sometimes android:autoVerify="true" cannot works when it can't access Google after installation. + * See https://stackoverflow.com/questions/76383106/auto-verify-not-always-working-in-app-links-using-android + * + * must register in AndroidManifest.xml : + * + * + * + * + * + */ + private fun fixAutoVerifyNotWorks(intent: Intent?): Uri? { + if (intent?.action == Intent.ACTION_SEND && "text/plain" == intent.type) { + return intent.getStringExtra(Intent.EXTRA_TEXT)?.let { + try { + //scheme://host/path#id_token=xxx + return Uri.parse(it) + } catch (e: Exception) { + return null + } + } + } + return null + } + +} diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/FlutterWebAuth2Plugin.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/FlutterWebAuth2Plugin.kt new file mode 100644 index 00000000..5569e002 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/FlutterWebAuth2Plugin.kt @@ -0,0 +1,165 @@ +package com.linusu.flutter_web_auth_2 + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +class FlutterWebAuth2Plugin( + private var context: Context? = null, + private var channel: MethodChannel? = null, + private var activity: Activity? = null, +) : MethodCallHandler, FlutterPlugin, ActivityAware { + companion object { + val callbacks = mutableMapOf() + } + + private fun initInstance(messenger: BinaryMessenger, context: Context) { + this.context = context + channel = MethodChannel(messenger, "flutter_web_auth_2") + channel?.setMethodCallHandler(this) + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + initInstance(binding.binaryMessenger, binding.applicationContext) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = null + channel = null + } + + override fun onMethodCall(call: MethodCall, resultCallback: Result) { + when (call.method) { + "authenticate" -> { + val url = Uri.parse(call.argument("url")) + val callbackUrlScheme: String = call.argument("callbackUrlScheme")!! + val options = call.argument>("options")!! + + callbacks[callbackUrlScheme] = resultCallback + activity?.startActivity(Intent(activity, AuthenticationManagementActivity::class.java).apply { + putExtra(AuthenticationManagementActivity.KEY_AUTH_URI, url) + putExtra(AuthenticationManagementActivity.KEY_AUTH_OPTION_INTENT_FLAGS, options["intentFlags"] as Int) + putExtra(AuthenticationManagementActivity.KEY_AUTH_OPTION_TARGET_PACKAGE, findTargetBrowserPackageName(options)) + putExtra(AuthenticationManagementActivity.KEY_AUTH_CALLBACK_SCHEME, callbackUrlScheme) + putExtra(AuthenticationManagementActivity.KEY_AUTH_CALLBACK_HOST, options["httpsHost"] as String?) + putExtra(AuthenticationManagementActivity.KEY_AUTH_CALLBACK_PATH, options["httpsPath"] as String?) + putExtra(AuthenticationManagementActivity.KEY_AUTH_OPTION_PREFER_EPHEMERAL, options["preferEphemeral"] as Boolean? ?: false) + }) + } + + "cleanUpDanglingCalls" -> { + callbacks.forEach { (_, danglingResultCallback) -> + danglingResultCallback.error("CANCELED", "User canceled login", null) + } + callbacks.clear() + resultCallback.success(null) + } + + else -> resultCallback.notImplemented() + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + activity = null + } + + /** + * Find Support CustomTabs Browser. + * + * Priority: + * 1. Custom Browser Order (if supported) + * 2. default Browser + * 3. Installed Browsers (if supported) + * 4. null (System backup aka. some obscure browser that may work) + */ + private fun findTargetBrowserPackageName(options: Map): String? { + val context = requireNotNull(context) { "Context is null" } + + val selectedPackage = (options["customTabsPackageOrder"] as? Iterable<*>) + ?.mapNotNull { (it as? String)?.trim() } + ?.filter { it.isNotEmpty() } + ?.firstOrNull { isSupportCustomTabs(it) } + + if (selectedPackage != null) { + return selectedPackage + } + + // Check default browser + val defaultBrowserSupported = CustomTabsClient.getPackageName(context, emptyList()) + if (defaultBrowserSupported != null) { + return defaultBrowserSupported + } + // Check installed browser + val matchedBrowser = getInstalledBrowsers().firstOrNull { isSupportCustomTabs(it) } + + // Don't fall back to Chrome here. It is not installed anyway because it would already be in matchedBrowser. + // Instead, fall back to null so we can use the system backup (if one is available). + return matchedBrowser + } + + private fun getInstalledBrowsers(): List { + val context = requireNotNull(context) { "Context is null" } + + // Get all apps that can handle VIEW intents + val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://")) + val viewIntentHandlers = context.getPackagesForIntent(activityIntent) + + val preferredKnownBrowsers = listOf( + PackageNames.CHROME_STABLE, + PackageNames.CHROME_BETA, + PackageNames.SAMSUNG_INTERNET, + PackageNames.MICROSOFT_EDGE, + PackageNames.FIREFOX, + PackageNames.CHROME_DEV, + ) + + val preferred = mutableListOf() + val others = mutableListOf() + val pushToEnd = mutableListOf() // for the least-favorite apps + + for (pkg in viewIntentHandlers) { + if (preferredKnownBrowsers.contains(pkg)) preferred += pkg + else others += pkg + } + + preferred.sortBy { preferredKnownBrowsers.indexOf(it) } + + return buildList { + addAll(preferred) + addAll(others) + addAll(pushToEnd) + } + } + + private fun isSupportCustomTabs(packageName: String): Boolean { + val value = CustomTabsClient.getPackageName( + context!!, + arrayListOf(packageName), + true + ) + return value == packageName + } +} diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/KeepAliveService.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/KeepAliveService.kt new file mode 100644 index 00000000..cd271793 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/KeepAliveService.kt @@ -0,0 +1,16 @@ +package com.linusu.flutter_web_auth_2 + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder + +class KeepAliveService: Service() { + companion object { + val binder = Binder() + } + + override fun onBind(intent: Intent): IBinder { + return binder + } +} diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/TabBuilderWrapper.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/TabBuilderWrapper.kt new file mode 100644 index 00000000..a7348358 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/TabBuilderWrapper.kt @@ -0,0 +1,65 @@ +package com.linusu.flutter_web_auth_2 + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsIntent + +interface TabBuilderWrapper { + fun setEphemeralBrowsingEnabled(enabled: Boolean): TabBuilderWrapper + fun build(): IntentWrapper +} + +interface IntentWrapper { + val intent: Intent + fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectHost: String, redirectPath: String) + fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectScheme: String) +} + +@SuppressLint("UnsafeOptInUsageError", "UnsafeOptInUsageWarning") +class CtBuilderWrapper(private val b: CustomTabsIntent.Builder) : TabBuilderWrapper { + override fun setEphemeralBrowsingEnabled(enabled: Boolean) = apply { b.setEphemeralBrowsingEnabled(enabled) } + + override fun build(): IntentWrapper { + val intent = b.build() + return object : IntentWrapper { + + override val intent: Intent + get() = intent.intent + + override fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectHost: String, redirectPath: String) { + intent.launchUrl(activity, url) + } + + override fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectScheme: String) { + intent.launchUrl(activity, url) + } + } + } +} + +@SuppressLint("UnsafeOptInUsageError", "UnsafeOptInUsageWarning") +class AuthTabBuilderWrapper(private val b: AuthTabIntent.Builder) : TabBuilderWrapper { + + override fun setEphemeralBrowsingEnabled(enabled: Boolean) = apply { b.setEphemeralBrowsingEnabled(enabled) } + + override fun build(): IntentWrapper { + val intent = b.build() + return object : IntentWrapper { + + override val intent: Intent + get() = intent.intent + + override fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectHost: String, redirectPath: String) { + intent.launch(launcher, url, redirectHost, redirectPath) + } + + override fun launch(activity: Activity, launcher: ActivityResultLauncher, url: Uri, redirectScheme: String) { + intent.launch(launcher, url, redirectScheme) + } + } + } +} diff --git a/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/Utils.kt b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/Utils.kt new file mode 100644 index 00000000..84fd8841 --- /dev/null +++ b/third_party/flutter_web_auth_2/android/src/main/kotlin/com/linusu/flutter_web_auth_2/Utils.kt @@ -0,0 +1,45 @@ +package com.linusu.flutter_web_auth_2 + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build + +object PackageNames { + const val CHROME_STABLE = "com.android.chrome" + const val CHROME_BETA = "com.chrome.beta" + const val CHROME_DEV = "com.chrome.dev" + const val MICROSOFT_EDGE = "com.microsoft.emmx" + const val FIREFOX = "org.mozilla.firefox" + const val SAMSUNG_INTERNET = "com.sec.android.app.sbrowser" +} + +val Any.LOG_TAG: String + get() = "flutter_web_auth_2" + +fun Context.getInstalledVersion(packageName: String): String? { + try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) + } + return packageInfo.versionName + } catch (_: Exception) { + return null + } +} + +fun Context.getPackagesForIntent(intent: Intent): List { + try { + val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + } else { + packageManager.queryIntentActivities(intent, 0) + } + return list.map { it.activityInfo.packageName } + } catch (_: Exception) { + return emptyList() + } +} diff --git a/third_party/flutter_web_auth_2/ios/flutter_web_auth_2.podspec b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2.podspec new file mode 100644 index 00000000..adf016da --- /dev/null +++ b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint flutter_web_auth_2.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'flutter_web_auth_2' + s.version = '5.0.0' + s.summary = 'Flutter plugin for authenticating a user with a web service.' + s.description = <<-DESC +Flutter plugin for authenticating a user with a web service. + DESC + s.homepage = 'https://github.com/ThexXTURBOXx/flutter_web_auth_2' + s.license = { :file => '../LICENSE' } + s.author = { 'Nico Mexis' => 'nico.mexis@kabelmail.de' } + s.source = { :path => '.' } + s.source_files = 'flutter_web_auth_2/Sources/flutter_web_auth_2/**/*.swift' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.9' +end diff --git a/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Package.swift b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Package.swift new file mode 100644 index 00000000..85459eec --- /dev/null +++ b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "flutter_web_auth_2", + platforms: [ + .iOS("11.0") + ], + products: [ + .library(name: "flutter-web-auth-2", targets: ["flutter_web_auth_2"]) + ], + dependencies: [], + targets: [ + .target( + name: "flutter_web_auth_2", + dependencies: [], + resources: [] + ) + ] +) diff --git a/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift new file mode 100644 index 00000000..d30d4218 --- /dev/null +++ b/third_party/flutter_web_auth_2/ios/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift @@ -0,0 +1,196 @@ +import AuthenticationServices +import Flutter +import SafariServices +import UIKit + +public class FlutterWebAuth2Plugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_web_auth_2", binaryMessenger: registrar.messenger()) + let instance = FlutterWebAuth2Plugin() + registrar.addMethodCallDelegate(instance, channel: channel) + registrar.addApplicationDelegate(instance) + } + + var completionHandler: ((URL?, Error?) -> Void)? + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "authenticate", + let arguments = call.arguments as? [String: AnyObject], + let urlString = arguments["url"] as? String, + let url = URL(string: urlString), + let callbackURLScheme = arguments["callbackUrlScheme"] as? String, + let options = arguments["options"] as? [String: AnyObject] + { + var sessionToKeepAlive: Any? // if we do not keep the session alive, it will get closed immediately while showing the dialog + completionHandler = { (url: URL?, err: Error?) in + self.completionHandler = nil + + if (sessionToKeepAlive != nil) { + if #available(iOS 12, *) { + (sessionToKeepAlive as! ASWebAuthenticationSession).cancel() + } else if #available(iOS 11, *) { + (sessionToKeepAlive as! SFAuthenticationSession).cancel() + } + sessionToKeepAlive = nil + } + + if let err = err { + if #available(iOS 12, *) { + if case ASWebAuthenticationSessionError.canceledLogin = err { + result( + FlutterError( + code: "CANCELED", + message: "User canceled login", + details: [ + "domain": (err as NSError).domain, + "code": (err as NSError).code, + "description": err.localizedDescription + ] + ) + ) + return + } + } + + if #available(iOS 11, *) { + if case SFAuthenticationError.canceledLogin = err { + result( + FlutterError( + code: "CANCELED", + message: "User canceled login", + details: [ + "domain": (err as NSError).domain, + "code": (err as NSError).code, + "description": err.localizedDescription + ] + ) + ) + return + } + } + + result(FlutterError(code: "EUNKNOWN", message: err.localizedDescription, details: nil)) + return + } + + guard let url = url else { + result(FlutterError(code: "EUNKNOWN", message: "URL was null, but no error provided.", details: nil)) + return + } + + result(url.absoluteString) + } + + if #available(iOS 12, *) { + var _session: ASWebAuthenticationSession? = nil + if #available(iOS 17.4, *) { + if (callbackURLScheme == "https") { + guard let host = options["httpsHost"] as? String else { + result(FlutterError.invalidHttpsHostError) + return + } + + guard let path = options["httpsPath"] as? String else { + result(FlutterError.invalidHttpsPathError) + return + } + + _session = ASWebAuthenticationSession(url: url, callback: ASWebAuthenticationSession.Callback.https(host: host, path: path), completionHandler: completionHandler!) + } else { + _session = ASWebAuthenticationSession(url: url, callback: ASWebAuthenticationSession.Callback.customScheme(callbackURLScheme), completionHandler: completionHandler!) + } + } else { + _session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler!) + } + let session = _session! + + if #available(iOS 13, *) { + var rootViewController: UIViewController? = nil + + // FlutterViewController + if (rootViewController == nil) { + rootViewController = UIApplication.shared.delegate?.window??.rootViewController as? FlutterViewController + } + + // UIViewController + if (rootViewController == nil) { + rootViewController = UIApplication.shared.keyWindow?.rootViewController + } + + // ACQUIRE_ROOT_VIEW_CONTROLLER_FAILED + if (rootViewController == nil) { + result(FlutterError.acquireRootViewControllerFailed) + return + } + + while let presentedViewController = rootViewController!.presentedViewController { + rootViewController = presentedViewController + } + if let nav = rootViewController as? UINavigationController { + rootViewController = nav.visibleViewController ?? rootViewController + } + + guard let contextProvider = rootViewController as? ASWebAuthenticationPresentationContextProviding else { + result(FlutterError.acquireRootViewControllerFailed) + return + } + session.presentationContextProvider = contextProvider + if let preferEphemeral = options["preferEphemeral"] as? Bool { + session.prefersEphemeralWebBrowserSession = preferEphemeral + } + } + + session.start() + sessionToKeepAlive = session + } else if #available(iOS 11, *) { + let session = SFAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler!) + session.start() + sessionToKeepAlive = session + } else { + result(FlutterError(code: "FAILED", message: "This plugin does currently not support iOS lower than iOS 11", details: nil)) + } + } else if call.method == "cleanUpDanglingCalls" { + // we do not keep track of old callbacks on iOS, so nothing to do here + result(nil) + } else { + result(FlutterMethodNotImplemented) + } + } + + public func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([Any]) -> Void) -> Bool + { + switch userActivity.activityType { + case NSUserActivityTypeBrowsingWeb: + guard let url = userActivity.webpageURL, let completionHandler = completionHandler else { + return false + } + completionHandler(url, nil) + return true + default: return false + } + } +} + +@available(iOS 13, *) +extension FlutterViewController: ASWebAuthenticationPresentationContextProviding { + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return view.window! + } +} + +fileprivate extension FlutterError { + static var acquireRootViewControllerFailed: FlutterError { + return FlutterError(code: "ACQUIRE_ROOT_VIEW_CONTROLLER_FAILED", message: "Failed to acquire root view controller", details: nil) + } + + static var invalidHttpsHostError: FlutterError { + return FlutterError(code: "INVALID_HTTPS_HOST_ERROR", message: "Failed to retrieve host for https scheme", details: nil) + } + + static var invalidHttpsPathError: FlutterError { + return FlutterError(code: "INVALID_HTTPS_PATH_ERROR", message: "Failed to retrieve path for https scheme", details: nil) + } +} diff --git a/third_party/flutter_web_auth_2/lib/flutter_web_auth_2.dart b/third_party/flutter_web_auth_2/lib/flutter_web_auth_2.dart new file mode 100644 index 00000000..734b5c84 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/flutter_web_auth_2.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_web_auth_2/src/options.dart'; +import 'package:flutter_web_auth_2/src/platform/platform_is.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; + +export 'src/options.dart'; +export 'src/unsupported.dart' + if (dart.library.io) 'src/linows.dart' + if (dart.library.js_interop) 'src/web.dart'; + +class _OnAppLifecycleResumeObserver extends WidgetsBindingObserver { + _OnAppLifecycleResumeObserver(this.onResumed); + + final Future Function() onResumed; + + @override + Future didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.resumed) { + await onResumed(); + } + } +} + +/// Provides all the functions you - as a user - should access. +class FlutterWebAuth2 { + static final RegExp _schemeRegExp = RegExp(r'^[a-z][a-z\d+.-]*$'); + + static FlutterWebAuth2Platform get _platform => + FlutterWebAuth2Platform.instance; + + static final _OnAppLifecycleResumeObserver _resumedObserver = + _OnAppLifecycleResumeObserver(_cleanUpDanglingCalls); + + static void _assertCallbackScheme(String callbackUrlScheme) { + if ((PlatformIs.web || (!PlatformIs.windows && !PlatformIs.linux)) && + !_schemeRegExp.hasMatch(callbackUrlScheme)) { + throw ArgumentError.value( + callbackUrlScheme, + 'callbackUrlScheme', + 'must be a valid URL scheme', + ); + } + } + + /// Ask the user to authenticate to the specified web service. + /// + /// The page pointed to by [url] will be loaded and displayed to the user. + /// From the page, the user can authenticate herself and grant access to the + /// app. On completion, the service will send a callback URL with an + /// authentication token, and this URL will be result of the returned + /// [Future]. + /// + /// [callbackUrlScheme] should be a string specifying the scheme of the URL + /// that the page will redirect to upon successful authentication. + /// If it is `https`, you also need to specify + /// [FlutterWebAuth2Options.httpsHost] and [FlutterWebAuth2Options.httpsPath] + /// on Apple devices running iOS >= 17.4 or macOS >= 14.4. This allows for + /// easy integration of Universal links. + /// + /// [options] can be used to specify either both general and + /// platform-specific settings. + static Future authenticate({ + required String url, + required String callbackUrlScheme, + FlutterWebAuth2Options options = const FlutterWebAuth2Options(), + }) async { + assert( + !(kIsWeb && options.debugOrigin != null && !kDebugMode), + 'Do not use debugOrigin in production', + ); + + _assertCallbackScheme(callbackUrlScheme); + + WidgetsBinding.instance.removeObserver( + _resumedObserver, + ); // safety measure so we never add this observer twice + WidgetsBinding.instance.addObserver(_resumedObserver); + return _platform.authenticate( + url: url, + callbackUrlScheme: callbackUrlScheme, + options: options.toJson(), + ); + } + + /// The plugin may need to store the resulting callbacks in order to pass + /// the result back to the caller of `authenticate`. But if that result never + /// comes the callback will dangle around forever. This can be called to + /// terminate all `authenticate` calls with an error. + static Future _cleanUpDanglingCalls() async { + await _platform.clearAllDanglingCalls(); + WidgetsBinding.instance.removeObserver(_resumedObserver); + } +} diff --git a/third_party/flutter_web_auth_2/lib/src/linows.dart b/third_party/flutter_web_auth_2/lib/src/linows.dart new file mode 100644 index 00000000..76d777d4 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/linows.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter_web_auth_2/src/server.dart'; +import 'package:flutter_web_auth_2/src/webview.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; + +/// Implements the plugin interface for Linux and Windows (Linows). +class FlutterWebAuth2LinowsPlugin extends FlutterWebAuth2Platform { + final FlutterWebAuth2Platform _webviewImpl = FlutterWebAuth2WebViewPlugin(); + final FlutterWebAuth2Platform _serverImpl = FlutterWebAuth2ServerPlugin(); + + /// Registers the Linows super-implementation. + static void registerWith() { + FlutterWebAuth2Platform.instance = FlutterWebAuth2LinowsPlugin(); + } + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + final parsedOptions = FlutterWebAuth2Options.fromJson(options); + if (parsedOptions.useWebview) { + return _webviewImpl.authenticate( + url: url, + callbackUrlScheme: callbackUrlScheme, + options: options, + ); + } + return _serverImpl.authenticate( + url: url, + callbackUrlScheme: callbackUrlScheme, + options: options, + ); + } + + @override + Future clearAllDanglingCalls() async { + await _serverImpl.clearAllDanglingCalls(); + await _webviewImpl.clearAllDanglingCalls(); + } +} diff --git a/third_party/flutter_web_auth_2/lib/src/macos.dart b/third_party/flutter_web_auth_2/lib/src/macos.dart new file mode 100644 index 00000000..5cc65372 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/macos.dart @@ -0,0 +1 @@ +// MacOS just needs the MethodChannel implementation diff --git a/third_party/flutter_web_auth_2/lib/src/options.dart b/third_party/flutter_web_auth_2/lib/src/options.dart new file mode 100644 index 00000000..1e4cea97 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/options.dart @@ -0,0 +1,183 @@ +/// Default intent flags for opening the custom tabs intent on Android. +const defaultIntentFlags = 0; + +/// "Ephemeral" intent flags for opening the custom tabs intent on Android. +@Deprecated('Use [preferEphemeral]') +const ephemeralIntentFlags = defaultIntentFlags | 1 << 30; + +/// Default HTML code that generates a nice callback page. +const _defaultLandingPage = ''' + + + + + Access Granted + + + + + +
+
This page should automatically close. + You may also close this page manually now.
+
+ + +'''; + +/// Provides configuration options for calls to `authenticate`. +class FlutterWebAuth2Options { + /// Construct an instance and specify the wanted options. + const FlutterWebAuth2Options({ + bool? preferEphemeral, + this.debugOrigin, + int? intentFlags, + this.windowName, + int? timeout, + String? landingPageHtml, + bool? silentAuth, + bool? useWebview, + this.httpsHost, + this.httpsPath, + this.customTabsPackageOrder, + }) : preferEphemeral = preferEphemeral ?? false, + intentFlags = intentFlags ?? defaultIntentFlags, + timeout = timeout ?? 5 * 60, + landingPageHtml = landingPageHtml ?? _defaultLandingPage, + silentAuth = silentAuth ?? false, + useWebview = useWebview ?? true; + + /// Construct an instance from JSON format. + FlutterWebAuth2Options.fromJson(Map json) + : this( + preferEphemeral: json['preferEphemeral'], + debugOrigin: json['debugOrigin'], + intentFlags: json['intentFlags'], + windowName: json['windowName'], + timeout: json['timeout'], + landingPageHtml: json['landingPageHtml'], + silentAuth: json['silentAuth'], + useWebview: json['useWebview'], + httpsHost: json['httpsHost'], + httpsPath: json['httpsPath'], + customTabsPackageOrder: json['customTabsPackageOrder'], + ); + + /// **Only has an effect on iOS, Android and macOS!** + /// If this is `true`, an ephemeral web browser session + /// will be used where possible. + final bool preferEphemeral; + + /// **Only has an effect on Web!** + /// Can be used to override the origin of the redirect URL. + /// This is useful for cases where the redirect URL is not on the same + /// domain (e.g. local testing). + final String? debugOrigin; + + /// **Only has an effect on Android!** + /// Can be used to configure the intent flags for the custom tabs intent. + /// Possible values can be found + /// [here](https://developer.android.com/reference/android/content/Intent#setFlags(int)) + /// or by using the flags from the `Flag` class from + /// [android_intent_plus](https://pub.dev/packages/android_intent_plus). + final int intentFlags; + + /// **Only has an effect on Web!** + /// Can be used to pass a window name for the URL open call. + /// See [here](https://www.w3schools.com/jsref/met_win_open.asp) for + /// possible parameter values. + final String? windowName; + + /// **Only has an effect on Linux, Web, and Windows!** + /// Can be used to specify a timeout in seconds when the authentication shall + /// be deemed unsuccessful. An error will be thrown in order to abort the + /// authentication process. + final int timeout; + + /// **Only has an effect on Linux and Windows with [useWebview] = `false`!** + /// Can be used to customise the landing page which tells the user that the + /// authentication was successful. It is the literal HTML source code which + /// will be displayed using a `HttpServer`. + final String landingPageHtml; + + /// **Only has an effect on Web!** + /// When set to `true`, the authentication URL will be loaded in a hidden + /// `iframe` instead of opening a new window or tab. This is primarily used + /// for silent authentication processes where a full-page redirect is not + /// desirable. It allows for a seamless user experience by performing the + /// authentication in the background. This approach is useful for token + /// refreshes or for maintaining user sessions without explicit user + /// interaction. IT IS YOUR RESPONSIBILITY TO MAKE SURE THAT THE URL IS SANE + /// AND DOES NOT CAUSE ANY HARM. IFRAMES CAN BE EXPLOITED IN MANY WAYS BY + /// MALICIOUS ATTACKERS! + final bool silentAuth; + + /// **Only has an effect on Linux and Windows!** + /// When set to `true`, use the new Webview implementation. + /// When set to `false`, the old approach using an internal server is being + /// used in order to fetch the HTTP result. When using the internal server, + /// please keep in mind that you cannot choose any callback URL scheme, as + /// described in https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues/25 + final bool useWebview; + + /// **Only has an effect on Android, iOS, Linux, macOS, and Windows!** + /// String specifying the **host** of the URL that the page will redirect to + /// upon successful authentication (callback URL). + /// When `callbackUrlScheme` is `https`, this **must** be specified on + /// Android and Apple devices running iOS >= 17.4 or macOS >= 14.4. + /// On Linux and Windows, this is used when `useWebview == true` in order to + /// compare with the host of the callback URL (no matter which scheme!). + final String? httpsHost; + + /// **Only has an effect on Android, iOS, Linux, macOS, and Windows!** + /// String specifying the **path** of the URL that the page will redirect to + /// upon successful authentication (callback URL). + /// When `callbackUrlScheme` is `https`, this **must** be specified on + /// Android and Apple devices running iOS >= 17.4 or macOS >= 14.4. + /// On Linux and Windows, this is used when `useWebview == true` in order to + /// compare with the path of the callback URL (no matter which scheme!). + final String? httpsPath; + + /// **Only has an effect on Android!** + /// Sets the Android browser priority for opening custom tabs. + /// Needs to be a list of packages providing a custom tabs + /// service. If a browser is not installed, the next on the list + /// is tested etc. + final List? customTabsPackageOrder; + + /// Convert this instance to JSON format. + Map toJson() => { + 'preferEphemeral': preferEphemeral, + 'debugOrigin': debugOrigin, + 'intentFlags': intentFlags, + 'windowName': windowName, + 'timeout': timeout, + 'landingPageHtml': landingPageHtml, + 'silentAuth': silentAuth, + 'useWebview': useWebview, + 'customTabsPackageOrder': customTabsPackageOrder, + 'httpsHost': httpsHost, + 'httpsPath': httpsPath, + }; +} diff --git a/third_party/flutter_web_auth_2/lib/src/platform/platform_is.dart b/third_party/flutter_web_auth_2/lib/src/platform/platform_is.dart new file mode 100644 index 00000000..97ddb42c --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/platform/platform_is.dart @@ -0,0 +1,73 @@ +// Adapted from https://gist.github.com/rydmike/1771fe24c050ebfe792fa309371154d8 + +import 'package:flutter_web_auth_2/src/platform/universal_platform_none.dart' + if (dart.library.io) 'package:flutter_web_auth_2/src/platform/universal_platform_vm.dart' + if (dart.library.js_interop) 'package:flutter_web_auth_2/src/platform/universal_platform_web.dart'; + +/// A universal platform checker. +/// +/// Can be used to check active physical Flutter platform on all platforms. +/// +/// To check what host platform the app is running on use: +/// +/// * PlatformIs.android +/// * PlatformIs.iOS +/// * PlatformIs.macOS +/// * PlatformIs.windows +/// * PlatformIs.linux +/// * PlatformIs.fuchsia +/// +/// To check the device type use: +/// +/// * PlatformIs.mobile (Android or iOS) +/// * PlatformIs.desktop (Windows, macOS or Linux) +/// +/// Currently Fuchsia is not considered mobile nor desktop, even if it +/// might be so in the future. +/// +/// To check if the Flutter application is running on Web you can use: +/// +/// * PlatformIs.web +/// +/// Alternatively the Flutter foundation compile time constant kIsWeb also +/// works well for that. +/// +/// The platform checks are supported independently on web. You can use +/// PlatformIs windows, iOS, macOS, Android and Linux to check what the host +/// platform is when you are running a Flutter Web application. +/// +/// Checking if we are running on a Fuchsia host in a Web browser, is not yet +/// fully supported. If running in a Web browser on Fuchsia, PlatformIs.web +/// will be true, but PlatformIs.fuchsia will be false. Future versions, when +/// Fuchsia is released, may fix this. +class PlatformIs { + PlatformIs._(); + + /// Is Web? + static bool get web => UniversalPlatform.web; + + /// Is macOS? + static bool get macOS => UniversalPlatform.macOS; + + /// Is Windows? + static bool get windows => UniversalPlatform.windows; + + /// Is Linux? + static bool get linux => UniversalPlatform.linux; + + /// Is Android? + static bool get android => UniversalPlatform.android; + + /// Is iOS? + static bool get iOS => UniversalPlatform.iOS; + + /// Is Fuchsia? + static bool get fuchsia => UniversalPlatform.fuchsia; + + /// Is Mobile? + static bool get mobile => PlatformIs.iOS || PlatformIs.android; + + /// Is Desktop? + static bool get desktop => + PlatformIs.macOS || PlatformIs.windows || PlatformIs.linux; +} diff --git a/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_none.dart b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_none.dart new file mode 100644 index 00000000..33ed4749 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_none.dart @@ -0,0 +1,33 @@ +// Adapted from https://gist.github.com/rydmike/1771fe24c050ebfe792fa309371154d8 + +// NOTE: +// Never import this library directly in the application. The PlatformIs +// class and library uses conditional imports to only import this file on +// VM platform builds. + +/// UniversalPlatform for just a "none" implementation when the platform +/// cannot be identified for some reason. +class UniversalPlatform { + UniversalPlatform._(); + + /// Is Web? + static bool get web => false; + + /// Is macOS? + static bool get macOS => false; + + /// Is Windows? + static bool get windows => false; + + /// Is Linux? + static bool get linux => false; + + /// Is Android? + static bool get android => false; + + /// Is iOS? + static bool get iOS => false; + + /// Is Fuchsia? + static bool get fuchsia => false; +} diff --git a/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_vm.dart b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_vm.dart new file mode 100644 index 00000000..88b531a3 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_vm.dart @@ -0,0 +1,37 @@ +// Adapted from https://gist.github.com/rydmike/1771fe24c050ebfe792fa309371154d8 + +import 'dart:io'; + +// NOTE: +// Never import this library directly in the application. The PlatformIs +// class and library uses conditional imports to only import this file on +// VM platform builds. + +/// UniversalPlatform for Flutter VM builds. +/// +/// We are using Dart VM builds, so we use dart:io Platform to +/// get the current platform. +class UniversalPlatform { + UniversalPlatform._(); + + /// Is Web? + static bool get web => false; + + /// Is macOS? + static bool get macOS => Platform.isMacOS; + + /// Is Windows? + static bool get windows => Platform.isWindows; + + /// Is Linux? + static bool get linux => Platform.isLinux; + + /// Is Android? + static bool get android => Platform.isAndroid; + + /// Is iOS? + static bool get iOS => Platform.isIOS; + + /// Is Fuchsia? + static bool get fuchsia => Platform.isFuchsia; +} diff --git a/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_web.dart b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_web.dart new file mode 100644 index 00000000..966bbd32 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/platform/universal_platform_web.dart @@ -0,0 +1,57 @@ +// Adapted from https://gist.github.com/rydmike/1771fe24c050ebfe792fa309371154d8 + +// ignore: avoid_web_libraries_in_flutter +import 'package:web/web.dart'; + +// NOTE: +// Never import this library directly in the application. The PlatformIs +// class and library uses conditional imports to only import this file on +// Web platform builds. + +final Navigator _nav = window.navigator; + +/// UniversalPlatform for Flutter WEB build. +/// +/// We can use dart:html Navigator to get the current platform. +/// +/// This function is borrowed, with minor modifications, from GetX utils library +/// with MIT license. +/// Credits for it belong to its author Jonny Borges https://github.com/jonataslaw +/// https://github.com/jonataslaw/getx/blob/master/lib/get_utils/src/platform/platform_web.dart +/// +class UniversalPlatform { + UniversalPlatform._(); + + /// Is Web? + static bool get web => true; + + /// Is macOS? + static bool get macOS => _nav.appVersion.contains('Mac OS') && !iOS; + + /// Is Windows? + static bool get windows => _nav.appVersion.contains('Win'); + + /// Is Linux? + static bool get linux => + (_nav.appVersion.contains('Linux') || _nav.appVersion.contains('x11')) && + !android; + + /// Is Android? + /// Source: https://developer.chrome.com/multidevice/user-agent + static bool get android => _nav.appVersion.contains('Android '); + + /// Is iOS? + /// maxTouchPoints is needed to separate iPad iOS13 vs new MacOS + static bool get iOS => + _hasMatch(_nav.platform, '/iPad|iPhone|iPod/') || + (_nav.platform == 'MacIntel' && _nav.maxTouchPoints > 1); + + /// Is Fuchsia? + /// Theoretically we could be in a Web browser on Fuchsia too, but + /// we have no info on how to get that info yet, so we return false. + static bool get fuchsia => false; +} + +bool _hasMatch(String? value, String pattern) => + // ignore: avoid_bool_literals_in_conditional_expressions + (value == null) ? false : RegExp(pattern).hasMatch(value); diff --git a/third_party/flutter_web_auth_2/lib/src/server.dart b/third_party/flutter_web_auth_2/lib/src/server.dart new file mode 100644 index 00000000..012c10d9 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/server.dart @@ -0,0 +1,71 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:window_to_front/window_to_front.dart'; + +/// Implements the plugin interface using an internal server (currently used by +/// Windows and Linux). +class FlutterWebAuth2ServerPlugin extends FlutterWebAuth2Platform { + HttpServer? _server; + Timer? _authTimeout; + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + final parsedOptions = FlutterWebAuth2Options.fromJson(options); + + // Validate callback url + final callbackUri = Uri.parse(callbackUrlScheme); + + if (callbackUri.scheme != 'http' || + (callbackUri.host != 'localhost' && callbackUri.host != '127.0.0.1') || + !callbackUri.hasPort) { + throw ArgumentError( + 'Callback url scheme must start with http://localhost:{port}', + ); + } + + await _server?.close(force: true); + + _server = await HttpServer.bind('127.0.0.1', callbackUri.port); + String? result; + + _authTimeout?.cancel(); + _authTimeout = Timer(Duration(seconds: parsedOptions.timeout), () { + _server?.close(); + }); + + await launchUrl(Uri.parse(url)); + + await _server!.listen((req) async { + req.response.headers.add('Content-Type', 'text/html'); + req.response.write(parsedOptions.landingPageHtml); + await req.response.close(); + + result = req.requestedUri.toString(); + await _server?.close(); + _server = null; + }).asFuture(); + + await _server?.close(force: true); + _authTimeout?.cancel(); + + if (result != null) { + await WindowToFront.activate(); + return result!; + } + throw PlatformException(message: 'User canceled login', code: 'CANCELED'); + } + + @override + Future clearAllDanglingCalls() async { + await _server?.close(force: true); + } +} diff --git a/third_party/flutter_web_auth_2/lib/src/unsupported.dart b/third_party/flutter_web_auth_2/lib/src/unsupported.dart new file mode 100644 index 00000000..dc402dfe --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/unsupported.dart @@ -0,0 +1,31 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; + +/// Implements the plugin interface for unsupported platforms (just throws +/// errors). +class FlutterWebAuth2UnsupportedPlugin extends FlutterWebAuth2Platform { + /// Registers the unsupported implementation. + static void registerWith() { + FlutterWebAuth2Platform.instance = FlutterWebAuth2UnsupportedPlugin(); + } + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + throw PlatformException( + message: 'Platform either unsupported or unrecognised.', + code: 'UNSUPPORTED', + ); + } + + @override + Future clearAllDanglingCalls() async { + throw PlatformException( + message: 'Platform either unsupported or unrecognised.', + code: 'UNSUPPORTED', + ); + } +} diff --git a/third_party/flutter_web_auth_2/lib/src/web.dart b/third_party/flutter_web_auth_2/lib/src/web.dart new file mode 100644 index 00000000..3c14f9ad --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/web.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/src/options.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web/web.dart'; + +/// Implements the plugin interface using iframes (for silent authentication) +/// and through an event listener system (for usual authentication). +class FlutterWebAuth2WebPlugin extends FlutterWebAuth2Platform { + /// Registers the web implementation. + static void registerWith(Registrar registrar) { + final channel = MethodChannel( + 'flutter_web_auth_2', + const StandardMethodCodec(), + registrar, + ); + final instance = FlutterWebAuth2WebPlugin(); + channel.setMethodCallHandler(instance._handleMethodCall); + FlutterWebAuth2Platform.instance = instance; + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'authenticate': + final url = call.arguments['url'].toString(); + return authenticate( + url: url, + callbackUrlScheme: '', + options: call.arguments['options'], + ); + case 'cleanUpDanglingCalls': + return clearAllDanglingCalls(); + default: + throw PlatformException( + code: 'Unimplemented', + details: "The flutter_web_auth_2 plugin for web doesn't implement " + "the method '${call.method}'", + ); + } + } + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + final parsedOptions = FlutterWebAuth2Options.fromJson(options); + + if (parsedOptions.silentAuth) { + final authIframe = (document.createElement('iframe') as HTMLIFrameElement) + ..src = url + ..style.display = 'none'; + + document.body?.append(authIframe); + + final completer = Completer(); + StreamSubscription? messageSubscription; + Timer? responseTimeoutTimer; + + messageSubscription = window.onMessage.listen((messageEvent) { + if (messageEvent.origin == + (parsedOptions.debugOrigin ?? Uri.base.origin)) { + final data = messageEvent.data.dartify(); + final authMessage = + data != null && data is Map ? data['flutter-web-auth-2'] : null; + if (authMessage is String) { + authIframe.remove(); + completer.complete(authMessage); + responseTimeoutTimer?.cancel(); + messageSubscription?.cancel(); + } + } + }); + + // Add a timeout for the iframe response + responseTimeoutTimer = + Timer(Duration(seconds: parsedOptions.timeout), () { + authIframe.remove(); + messageSubscription?.cancel(); + completer.completeError( + PlatformException( + code: 'timeout', + message: 'Timeout waiting for the iframe response', + ), + ); + }); + + return completer.future; + } + + await launchUrl( + Uri.parse(url), + webOnlyWindowName: parsedOptions.windowName, + ); + + // Remove the old record if it exists + const storageKey = 'flutter-web-auth-2'; + window.localStorage.removeItem(storageKey); + Timer? lsTimer; + StreamSubscription? messageSubscription; + final completer = Completer(); + try { + // Periodically check for the callback value in local storage. + // If it exists, return it. If not, check the timeout. + // If the timeout has passed, throw an exception. + lsTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final flutterWebAuthMessage = window.localStorage.getItem(storageKey); + if (flutterWebAuthMessage != null) { + completer.complete(flutterWebAuthMessage); + window.localStorage.removeItem(storageKey); + messageSubscription?.cancel(); + timer.cancel(); + } else if (timer.tick >= parsedOptions.timeout) { + messageSubscription?.cancel(); + timer.cancel(); + completer.completeError( + PlatformException( + code: 'error', + message: 'Timeout waiting for callback value', + ), + ); + } + }); + + // Traditional window.opener method of listening for the redirect + messageSubscription = window.onMessage.listen( + (messageEvent) { + if (messageEvent.origin == + (parsedOptions.debugOrigin ?? Uri.base.origin)) { + final data = messageEvent.data.dartify(); + final flutterWebAuthMessage = + data != null && data is Map ? data['flutter-web-auth-2'] : null; + if (flutterWebAuthMessage is String) { + lsTimer?.cancel(); + messageSubscription?.cancel(); + completer.complete(flutterWebAuthMessage); + } + } + + final appleOrigin = Uri(scheme: 'https', host: 'appleid.apple.com'); + if (messageEvent.origin == appleOrigin.toString()) { + try { + final data = jsonDecode(messageEvent.data.toString()); + if (data['method'] == 'oauthDone') { + final appleAuth = + data['data']['authorization'] as Map?; + if (appleAuth != null) { + final appleAuthQuery = Uri(queryParameters: appleAuth).query; + lsTimer?.cancel(); + messageSubscription?.cancel(); + completer.complete( + appleOrigin.replace(fragment: appleAuthQuery).toString(), + ); + } + } + } on FormatException { + // ignore exception + } + } + }, + onError: (error) { + lsTimer?.cancel(); + messageSubscription?.cancel(); + throw error; + }, + ); + } catch (e) { + lsTimer?.cancel(); + completer.completeError(e); + } + + return completer.future; + } + + @override + Future clearAllDanglingCalls() async {} +} diff --git a/third_party/flutter_web_auth_2/lib/src/webview.dart b/third_party/flutter_web_auth_2/lib/src/webview.dart new file mode 100644 index 00000000..0e0ad640 --- /dev/null +++ b/third_party/flutter_web_auth_2/lib/src/webview.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Implements the plugin interface using the Webview interface (currently used +/// by Windows and Linux). +class FlutterWebAuth2WebViewPlugin extends FlutterWebAuth2Platform { + bool _authenticated = false; + Webview? _webview; + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + if (!await WebviewWindow.isWebviewAvailable()) { + // Microsoft's WebView2 must be installed for this to work + throw StateError('Webview is not available'); + } + + final parsedOptions = FlutterWebAuth2Options.fromJson(options); + + // Reset + _authenticated = false; + _webview?.close(); + + final c = Completer(); + _webview = await WebviewWindow.create( + configuration: CreateConfiguration( + windowHeight: 720, + windowWidth: 1280, + title: 'Authenticate', + titleBarTopPadding: 0, + userDataFolderWindows: (await getTemporaryDirectory()).path, + ), + ); + _webview!.setOnUrlRequestCallback((url) { + final uri = Uri.parse(url); + if (uri.scheme != callbackUrlScheme || + (parsedOptions.httpsHost != null && + uri.host != parsedOptions.httpsHost) || + (parsedOptions.httpsPath != null && + uri.path != parsedOptions.httpsPath)) { + return true; + } + _authenticated = true; + _webview?.close(); + /** + * Not setting the webview to null will cause a crash if the + * application tries to open another webview + */ + _webview = null; + c.complete(url); + return true; + }); + unawaited( + _webview!.onClose.whenComplete( + () { + /** + * Not setting the webview to null will cause a crash if the + * application tries to open another webview + */ + _webview = null; + if (!_authenticated) { + c.completeError( + PlatformException(code: 'CANCELED', message: 'User canceled'), + ); + } + }, + ), + ); + _webview!.launch(url); + return c.future; + } + + @override + Future clearAllDanglingCalls() async {} +} diff --git a/third_party/flutter_web_auth_2/macos/flutter_web_auth_2.podspec b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2.podspec new file mode 100644 index 00000000..1a196e25 --- /dev/null +++ b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint flutter_web_auth_2.podspec` to validate before publishing. +# +Pod::Spec.new do |s| +s.name = 'flutter_web_auth_2' +s.version = '5.0.0' +s.summary = 'Flutter plugin for authenticating a user with a web service.' +s.description = <<-DESC + Flutter plugin for authenticating a user with a web service. +DESC + s.homepage = 'https://github.com/ThexXTURBOXx/flutter_web_auth_2' +s.license = { :file => '../LICENSE' } +s.author = { 'Nico Mexis' => 'nico.mexis@kabelmail.de' } + +s.source = { :path => '.' } +s.source_files = 'flutter_web_auth_2/Sources/flutter_web_auth_2/**/*.swift' +s.dependency 'FlutterMacOS' +s.platform = :osx, '10.15' + +s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } +s.swift_version = '5.9' +end diff --git a/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Package.swift b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Package.swift new file mode 100644 index 00000000..96167ee2 --- /dev/null +++ b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "flutter_web_auth_2", + platforms: [ + .macOS("10.15") + ], + products: [ + .library(name: "flutter-web-auth-2", targets: ["flutter_web_auth_2"]) + ], + dependencies: [], + targets: [ + .target( + name: "flutter_web_auth_2", + dependencies: [], + resources: [] + ) + ] +) diff --git a/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift new file mode 100644 index 00000000..c12ed04e --- /dev/null +++ b/third_party/flutter_web_auth_2/macos/flutter_web_auth_2/Sources/flutter_web_auth_2/FlutterWebAuth2Plugin.swift @@ -0,0 +1,94 @@ +import AuthenticationServices +import SafariServices +import FlutterMacOS + +@available(OSX 10.15, *) +public class FlutterWebAuth2Plugin: NSObject, FlutterPlugin, ASWebAuthenticationPresentationContextProviding { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_web_auth_2", binaryMessenger: registrar.messenger) + let instance = FlutterWebAuth2Plugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "authenticate", + let arguments = call.arguments as? [String: AnyObject], + let urlString = arguments["url"] as? String, + let url = URL(string: urlString), + let callbackURLScheme = arguments["callbackUrlScheme"] as? String, + let options = arguments["options"] as? [String: AnyObject] + { + var sessionToKeepAlive: Any? // if we do not keep the session alive, it will get closed immediately while showing the dialog + let completionHandler = { (url: URL?, err: Error?) in + sessionToKeepAlive = nil + + if let err = err { + if case ASWebAuthenticationSessionError.canceledLogin = err { + result(FlutterError(code: "CANCELED", message: "User canceled login", details: nil)) + return + } + + result(FlutterError(code: "EUNKNOWN", message: err.localizedDescription, details: nil)) + return + } + + guard let url = url else { + result(FlutterError(code: "EUNKNOWN", message: "URL was null, but no error provided.", details: nil)) + return + } + + result(url.absoluteString) + } + + var _session: ASWebAuthenticationSession? = nil + if #available(macOS 14.4, *) { + if (callbackURLScheme == "https") { + guard let host = options["httpsHost"] as? String else { + result(FlutterError.invalidHttpsHostError) + return + } + + guard let path = options["httpsPath"] as? String else { + result(FlutterError.invalidHttpsPathError) + return + } + + _session = ASWebAuthenticationSession(url: url, callback: ASWebAuthenticationSession.Callback.https(host: host, path: path), completionHandler: completionHandler) + } else { + _session = ASWebAuthenticationSession(url: url, callback: ASWebAuthenticationSession.Callback.customScheme(callbackURLScheme), completionHandler: completionHandler) + } + } else { + _session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler) + } + let session = _session! + + if let preferEphemeral = options["preferEphemeral"] as? Bool { + session.prefersEphemeralWebBrowserSession = preferEphemeral + } + session.presentationContextProvider = self + + session.start() + sessionToKeepAlive = session + } else if call.method == "cleanUpDanglingCalls" { + // we do not keep track of old callbacks on macOS, so nothing to do here + result(nil) + } else { + result(FlutterMethodNotImplemented) + } + } + + @available(macOS 10.15, *) + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return NSApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} + +fileprivate extension FlutterError { + static var invalidHttpsHostError: FlutterError { + return FlutterError(code: "INVALID_HTTPS_HOST_ERROR", message: "Failed to retrieve host for https scheme", details: nil) + } + + static var invalidHttpsPathError: FlutterError { + return FlutterError(code: "INVALID_HTTPS_PATH_ERROR", message: "Failed to retrieve path for https scheme", details: nil) + } +} diff --git a/third_party/flutter_web_auth_2/pubspec.yaml b/third_party/flutter_web_auth_2/pubspec.yaml new file mode 100644 index 00000000..fd5b4f86 --- /dev/null +++ b/third_party/flutter_web_auth_2/pubspec.yaml @@ -0,0 +1,62 @@ +name: flutter_web_auth_2 +description: Flutter plugin for authenticating a user with a web service. +version: 5.1.0 +resolution: workspace +homepage: https://github.com/ThexXTURBOXx/flutter_web_auth_2 +repository: https://github.com/ThexXTURBOXx/flutter_web_auth_2 +issue_tracker: https://github.com/ThexXTURBOXx/flutter_web_auth_2/issues +funding: + - https://github.com/sponsors/ThexXTURBOXx +screenshots: + - description: "flutter_web_auth_2 in action on Android" + path: screen-android.gif + - description: "flutter_web_auth_2 in action on iOS" + path: screen-ios.gif + - description: "flutter_web_auth_2 in action on MacOS" + path: screen-macos.gif +topics: + - authentication + - network + - oauth + - web + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + desktop_webview_window: ^0.3.0 + flutter: + sdk: flutter + flutter_web_auth_2_platform_interface: ^5.0.0 + flutter_web_plugins: + sdk: flutter + path_provider: ^2.1.2 + url_launcher: ^6.1.6 + web: ">=0.5.0 <2.0.0" + window_to_front: ^0.0.3 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.linusu.flutter_web_auth_2 + pluginClass: FlutterWebAuth2Plugin + ios: + pluginClass: FlutterWebAuth2Plugin + linux: + dartPluginClass: FlutterWebAuth2LinowsPlugin + fileName: src/linows.dart + macos: + pluginClass: FlutterWebAuth2Plugin + web: + pluginClass: FlutterWebAuth2WebPlugin + fileName: src/web.dart + windows: + dartPluginClass: FlutterWebAuth2LinowsPlugin + fileName: src/linows.dart diff --git a/third_party/keyboard_height_plugin/CHANGELOG.md b/third_party/keyboard_height_plugin/CHANGELOG.md new file mode 100644 index 00000000..537b6ac1 --- /dev/null +++ b/third_party/keyboard_height_plugin/CHANGELOG.md @@ -0,0 +1,67 @@ +## 0.3.0 + +### iOS Updates +* Add Swift Package Manager support (fixes #17) + * Flutter 3.44+ will default to SwiftPM; CocoaPods support is retained for backward compatibility + * Added `Package.swift` manifest at `ios/keyboard_height_plugin/` + * Source files are symlinked so both CocoaPods and SwiftPM resolve the same code + +## 0.2.0 + +**BREAKING CHANGES:** +This version requires modern Android build tooling. For projects using older versions, please continue using version 0.1.5. + +### Requirements +- Android Gradle Plugin 8.6.0+ +- Gradle 8.7+ +- Kotlin 2.1.0+ +- Java 17+ + +### Android Updates +* **BREAKING:** Update compileSdkVersion from 31 to 34 (fixes #14) +* **BREAKING:** Update Android Gradle Plugin from 7.2.0 to 8.6.0 +* **BREAKING:** Update Kotlin from 1.7.10 to 2.1.0 +* **BREAKING:** Migrate to declarative Gradle plugin system (Gradle 8.x requirement) +* Add namespace declaration for AGP 8.6+ compatibility +* minSdkVersion remains 16 (Android 4.1+) - no change to device compatibility + +### iOS Updates +* Add Privacy Manifest (PrivacyInfo.xcprivacy) required for App Store submissions (fixes #6) +* Fix example app Podfile configuration to eliminate RunnerTests errors + +### Infrastructure +* Add comprehensive CI/CD pipeline with GitHub Actions +* Add automated testing for code analysis, formatting, and builds +* Add PR template with testing checklist +* Add TESTING.md documentation +* Update example app .gitignore for Flutter-generated files + +### Migration Guide +If you need to upgrade from 0.1.5: +1. Update your project to use AGP 8.6+, Gradle 8.7+, Kotlin 2.1+, and Java 17+ +2. Follow the Flutter AGP migration guide: https://docs.flutter.dev/release/breaking-changes/flutter-gradle-plugin-apply + +If your project cannot upgrade these dependencies yet, remain on version 0.1.5. + +## 0.0.1 + +* Initial release. + +## 0.0.2 + +* Documentation. + +## 0.0.3 + +* More documentation. + +## 0.0.4 + +* N/A + +## 0.0.5 + +* Support for AGP 8.0.0 + +## 0.1.5 +* Merge in fix for incorrect height when navigation bar is not visible. https://github.com/nschairer/keyboard_height_plugin/pull/8/files diff --git a/third_party/keyboard_height_plugin/LICENSE b/third_party/keyboard_height_plugin/LICENSE new file mode 100644 index 00000000..005d5993 --- /dev/null +++ b/third_party/keyboard_height_plugin/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023, Noah Schairer +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/keyboard_height_plugin/README.md b/third_party/keyboard_height_plugin/README.md new file mode 100644 index 00000000..ec35b6d6 --- /dev/null +++ b/third_party/keyboard_height_plugin/README.md @@ -0,0 +1,85 @@ +# Keyboard Height Plugin for Flutter + +`keyboard_height_plugin` is a Flutter plugin for iOS and Android that provides the keyboard size before the keyboard animation occurs for showing or hiding it. This helps eliminate lag when positioning widgets around the keyboard, such as placing a TextField above the keyboard. + +## Requirements + +### Version 0.3.0+ (Current) +- **Android:** + - Android Gradle Plugin (AGP) 8.6.0+ + - Gradle 8.7+ + - Kotlin 2.1.0+ + - Java 17+ + - minSdkVersion 16 (Android 4.1+) + - compileSdkVersion 34+ +- **iOS:** + - iOS 12.0+ + - Supports both CocoaPods and Swift Package Manager + - Includes Privacy Manifest for App Store compliance + +### Version 0.1.5 (Legacy Support) +For projects that cannot upgrade to the modern build tools, use version 0.1.5: +- Android Gradle Plugin 7.x +- Kotlin 1.7.x +- Java 11+ + +## Installation + +To install `keyboard_height_plugin`, add it to your `pubspec.yaml` file under the `dependencies` section: + +```yaml +dependencies: + keyboard_height_plugin: ^0.3.0 +``` + +**For legacy projects:** +```yaml +dependencies: + keyboard_height_plugin: 0.1.5 +``` + +## Usage + +To use the `keyboard_height_plugin`, first import it in your Dart file: + +```dart +import 'package:keyboard_height_plugin/keyboard_height_plugin.dart'; +``` + +Next, create a stateful widget and declare a variable to store the keyboard height and create an instance of the `KeyboardHeightPlugin`: + +```dart +class _HomePageState extends State; { + double _keyboardHeight = 0; + final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin(); + // ... rest of code ... +} +``` + +Then, initialize the `KeyboardHeightPlugin` in your `initState` method and listen for changes in the keyboard height: + +```dart +@override +void initState() { + super.initState(); + _keyboardHeightPlugin.onKeyboardHeightChanged((double height) { + setState(() { + _keyboardHeight = height; + }); + }); +} +``` + +Use the `_keyboardHeight` variable to position your widgets around the keyboard. + +## Example + +For a complete example on how to use the `keyboard_height_plugin`, please refer to the [`example`](example) directory in the repository. + +## Contributing + +If you encounter any issues or have suggestions for improvements, feel free to open an issue or submit a pull request on the project's GitHub repository. + +## License + +This plugin is licensed under the [BSD 3-Clause License](LICENSE). diff --git a/third_party/keyboard_height_plugin/android/build.gradle b/third_party/keyboard_height_plugin/android/build.gradle new file mode 100644 index 00000000..ede28f17 --- /dev/null +++ b/third_party/keyboard_height_plugin/android/build.gradle @@ -0,0 +1,53 @@ +group 'com.nschairer.keyboard_height_plugin' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '2.1.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.6.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 34 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 16 + } + if (project.android.hasProperty("namespace")) { + namespace 'com.nschairer.keyboard_height_plugin' + } +} + +// Halkyon's patch (4 Nov 2026): Kotlin built into the Android Gradle plugin +// (AGP 9, android.builtInKotlin=true) rather than the Kotlin Gradle plugin +// applied here, as Flutter's guide for plugin authors has it. +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + } +} diff --git a/third_party/keyboard_height_plugin/android/settings.gradle b/third_party/keyboard_height_plugin/android/settings.gradle new file mode 100644 index 00000000..2c4aab0f --- /dev/null +++ b/third_party/keyboard_height_plugin/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'keyboard_height_plugin' diff --git a/third_party/keyboard_height_plugin/android/src/main/AndroidManifest.xml b/third_party/keyboard_height_plugin/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e0b2d82b --- /dev/null +++ b/third_party/keyboard_height_plugin/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/keyboard_height_plugin/android/src/main/kotlin/com/nschairer/keyboard_height_plugin/KeyboardHeightPlugin.kt b/third_party/keyboard_height_plugin/android/src/main/kotlin/com/nschairer/keyboard_height_plugin/KeyboardHeightPlugin.kt new file mode 100644 index 00000000..96302a00 --- /dev/null +++ b/third_party/keyboard_height_plugin/android/src/main/kotlin/com/nschairer/keyboard_height_plugin/KeyboardHeightPlugin.kt @@ -0,0 +1,93 @@ +package com.nschairer.keyboard_height_plugin +import android.graphics.Rect +import android.os.Build +import androidx.annotation.NonNull +import android.view.ViewTreeObserver +import androidx.annotation.RequiresApi +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class KeyboardHeightPlugin : FlutterPlugin, EventChannel.StreamHandler, ActivityAware { + private val keyboardHeightEventChannelName = "keyboardHeightEventChannel" + private var eventSink: EventChannel.EventSink? = null + private var eventChannel: EventChannel? = null + private var activityPluginBinding: ActivityPluginBinding? = null + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, keyboardHeightEventChannelName) + eventChannel?.setStreamHandler(this) + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + eventChannel?.setStreamHandler(null) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + val rootView = activityPluginBinding?.activity?.window?.decorView?.rootView + rootView?.viewTreeObserver?.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + val r = Rect() + rootView.getWindowVisibleDisplayFrame(r) + + val screenHeight = rootView.height + val navigationBarHeight = getNavigationBarHeight(); + var keypadHeight = screenHeight - r.bottom + if (isNavigationBarVisible()) { + keypadHeight -= navigationBarHeight + } + val displayMetrics = activityPluginBinding?.activity?.resources?.displayMetrics + val logicalKeypadHeight = keypadHeight / (displayMetrics?.density ?: 1f) + + if (keypadHeight > screenHeight * 0.15) { + events?.success(logicalKeypadHeight.toDouble()) + } else { + events?.success(0.0) + } + } + }) + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + private fun getNavigationBarHeight(): Int { + val resourceId = activityPluginBinding?.activity?.resources?.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId != null && resourceId > 0) { + activityPluginBinding?.activity?.resources?.getDimensionPixelSize(resourceId) ?: 0 + } else { + 0 + } + } + + private fun isNavigationBarVisible(): Boolean { + val decorView = activityPluginBinding?.activity?.window?.decorView + val rootWindowInsets = decorView?.rootWindowInsets ?: return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + rootWindowInsets.isVisible(android.view.WindowInsets.Type.navigationBars()) + } else { + val systemWindowInsetBottom = rootWindowInsets.systemWindowInsetBottom + systemWindowInsetBottom > 0 + } + } + + // Implement ActivityAware methods + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityPluginBinding = binding + } + + override fun onDetachedFromActivityForConfigChanges() { + activityPluginBinding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activityPluginBinding = binding + } + + override fun onDetachedFromActivity() { + activityPluginBinding = null + } +} diff --git a/third_party/keyboard_height_plugin/ios/Classes/KeyboardHeightPlugin.swift b/third_party/keyboard_height_plugin/ios/Classes/KeyboardHeightPlugin.swift new file mode 100644 index 00000000..bb041f78 --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/Classes/KeyboardHeightPlugin.swift @@ -0,0 +1,47 @@ +import Flutter +import UIKit + +public class KeyboardHeightPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private var eventChannel: FlutterEventChannel? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = KeyboardHeightPlugin() + instance.eventChannel = FlutterEventChannel(name: "keyboardHeightEventChannel", binaryMessenger: registrar.messenger()) + instance.eventChannel?.setStreamHandler(instance) + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + registerKeyboardObservers() + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + unregisterKeyboardObservers() + return nil + } + + private func registerKeyboardObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + private func unregisterKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc private func keyboardWillShow(notification: NSNotification) { + if let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + eventSink?(NSNumber(value: keyboardFrame.height)) + } + } + + @objc private func keyboardWillHide(notification: NSNotification) { + eventSink?(NSNumber(value: 0.0)) + } +} + diff --git a/third_party/keyboard_height_plugin/ios/PrivacyInfo.xcprivacy b/third_party/keyboard_height_plugin/ios/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..5704beda --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/third_party/keyboard_height_plugin/ios/keyboard_height_plugin.podspec b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin.podspec new file mode 100644 index 00000000..96ac0f97 --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint keyboard_height_plugin.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'keyboard_height_plugin' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.resource_bundles = {'keyboard_height_plugin_privacy' => ['PrivacyInfo.xcprivacy']} + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Package.swift b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Package.swift new file mode 100644 index 00000000..753ce066 --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "keyboard_height_plugin", + platforms: [ + .iOS(.v12) + ], + products: [ + .library(name: "keyboard-height-plugin", targets: ["keyboard_height_plugin"]) + ], + dependencies: [], + targets: [ + .target( + name: "keyboard_height_plugin", + resources: [ + .process("PrivacyInfo.xcprivacy") + ] + ) + ] +) diff --git a/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/KeyboardHeightPlugin.swift b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/KeyboardHeightPlugin.swift new file mode 100644 index 00000000..bb041f78 --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/KeyboardHeightPlugin.swift @@ -0,0 +1,47 @@ +import Flutter +import UIKit + +public class KeyboardHeightPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private var eventChannel: FlutterEventChannel? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = KeyboardHeightPlugin() + instance.eventChannel = FlutterEventChannel(name: "keyboardHeightEventChannel", binaryMessenger: registrar.messenger()) + instance.eventChannel?.setStreamHandler(instance) + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + registerKeyboardObservers() + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + unregisterKeyboardObservers() + return nil + } + + private func registerKeyboardObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + private func unregisterKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc private func keyboardWillShow(notification: NSNotification) { + if let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + eventSink?(NSNumber(value: keyboardFrame.height)) + } + } + + @objc private func keyboardWillHide(notification: NSNotification) { + eventSink?(NSNumber(value: 0.0)) + } +} + diff --git a/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/PrivacyInfo.xcprivacy b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..5704beda --- /dev/null +++ b/third_party/keyboard_height_plugin/ios/keyboard_height_plugin/Sources/keyboard_height_plugin/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/third_party/keyboard_height_plugin/lib/keyboard_height_plugin.dart b/third_party/keyboard_height_plugin/lib/keyboard_height_plugin.dart new file mode 100644 index 00000000..611f2611 --- /dev/null +++ b/third_party/keyboard_height_plugin/lib/keyboard_height_plugin.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +typedef KeyboardHeightCallback = void Function(double height); + +class KeyboardHeightPlugin { + static const EventChannel _keyboardHeightEventChannel = + EventChannel('keyboardHeightEventChannel'); + + StreamSubscription? _keyboardHeightSubscription; + + void onKeyboardHeightChanged(KeyboardHeightCallback callback) { + if (_keyboardHeightSubscription != null) { + _keyboardHeightSubscription!.cancel(); + } + _keyboardHeightSubscription = + _keyboardHeightEventChannel.receiveBroadcastStream().listen( + (dynamic height) { + callback(height as double); + }, + ); + } + + void dispose() { + if (_keyboardHeightSubscription != null) { + _keyboardHeightSubscription!.cancel(); + } + } +} diff --git a/third_party/keyboard_height_plugin/pubspec.yaml b/third_party/keyboard_height_plugin/pubspec.yaml new file mode 100644 index 00000000..d77ba285 --- /dev/null +++ b/third_party/keyboard_height_plugin/pubspec.yaml @@ -0,0 +1,72 @@ +name: keyboard_height_plugin +description: Flutter plugin that emits keyboard height before it shows (ios/android) +version: 0.3.0 +repository: https://github.com/nschairer/keyboard_height_plugin + +environment: + sdk: '>=2.19.6 <4.0.0' + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.nschairer.keyboard_height_plugin + pluginClass: KeyboardHeightPlugin + ios: + pluginClass: KeyboardHeightPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/tool/src/check_files.dart b/tool/src/check_files.dart index 5908ab60..f843bffc 100644 --- a/tool/src/check_files.dart +++ b/tool/src/check_files.dart @@ -19,10 +19,15 @@ final _written = RegExp( r'^packages/halkyon_base_client/(lib/srp\.dart$|lib/src/srp/|test/)', ); +/// Others' plugins kept patched in the repository (`third_party/`), formatted +/// as their authors keep them. +final _others = RegExp('^third_party/'); + /// Whether [path] (relative to the repository's root) is a Dart file of our /// own, which the formatting check covers. bool isOwnDartFile(String path) => path.endsWith('.dart') && + !_others.hasMatch(path) && (_written.hasMatch(path) || !_generated.hasMatch(path)); /// The root's files every package's resolution or analysis depends on.