diff --git a/.gitignore b/.gitignore index 9c6eea097..5592acbf9 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,7 @@ android/app/src/main/jniLibs/ /third_party/fvp/android/.cxx/ /third_party/fvp/android/mdk-sdk/ /third_party/fvp/android/mdk-sdk-*.7z + +# OHOS plugin overlay working copies (scripts/enable-ohos-plugins.sh) +/pubspec.yaml.ohos-backup +/pubspec.lock.ohos-backup diff --git a/pubspec.lock b/pubspec.lock index f98aebdd9..6dd7b9e3d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + app_links_ohos: + dependency: "direct main" + description: + name: app_links_ohos + sha256: "00c85e64f8e770570748a66659f008cca0be13d18d5286afd536b7555676e2ba" + url: "https://pub.dev" + source: hosted + version: "0.2.0" app_links_platform_interface: dependency: transitive description: @@ -968,6 +976,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + local_auth_ohos: + dependency: "direct main" + description: + name: local_auth_ohos + sha256: "9287c3f347b2530aeb7b777f2ffd1c1df256866430634735cb5418fd186d9fc7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" local_auth_platform_interface: dependency: transitive description: @@ -1096,6 +1112,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.0" + open_filex_ohos: + dependency: "direct main" + description: + name: open_filex_ohos + sha256: aefe74a34ec21e08e609c4cb7c3e4f5110512ea1c5bedf72bfddc44f9fb103e1 + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -1168,6 +1192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + path_provider_ohos: + dependency: "direct main" + description: + name: path_provider_ohos + sha256: "6b479a99543743a9e0882f69521345b1ec78ceacb50ba155dffd43d01b1f84d0" + url: "https://pub.dev" + source: hosted + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: @@ -1464,6 +1496,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + sensors_plus_ohos: + dependency: "direct main" + description: + name: sensors_plus_ohos + sha256: aa179afb15d2834dfde0cbb5271f1a541a5859e0258467085a535e0c142065cb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sensors_plus_platform_interface: dependency: transitive description: @@ -1520,6 +1560,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shared_preferences_ohos: + dependency: "direct main" + description: + name: shared_preferences_ohos + sha256: "4f143582552822b4198491a5ddfa066e3e2658d646a8e55302267e77379f00d0" + url: "https://pub.dev" + source: hosted + version: "2.2.0" shared_preferences_platform_interface: dependency: "direct dev" description: @@ -1725,6 +1773,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.6" + url_launcher_ohos: + dependency: "direct main" + description: + name: url_launcher_ohos + sha256: a6ab56765be876fbcfac6f2a8de4f5bc5dc62b0c66e0fa37e33db263cb5d4ba7 + url: "https://pub.dev" + source: hosted + version: "6.3.0" url_launcher_platform_interface: dependency: transitive description: @@ -1877,6 +1933,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.14.1" + webview_flutter_ohos: + dependency: "direct main" + description: + name: webview_flutter_ohos + sha256: da80d6318df3ca437898f21c04249cfe63e4df2893b7fd2a4b4e9cecf964bb6b + url: "https://pub.dev" + source: hosted + version: "3.15.0" webview_flutter_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f6dc79781..42bfedf11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -135,6 +135,38 @@ dependencies: local_auth: ^3.0.2 app_links: ^7.2.1 + # HarmonyOS (OpenHarmony) federated plugin implementations. Each port below + # declares a `platforms: ohos` plugin (most also `implements:` the host + # package); stock flutter_tools ignores the ohos platform on Android/iOS/ + # desktop, while the OHOS Flutter fork resolves these as the platform + # implementation when building for HarmonyOS. Regular dependencies (not + # overrides) so the OHOS solver sees them; harmless elsewhere. + # The remaining ports (package_info_plus / permission_handler / file_picker / + # geolocator / flutter_secure_storage _ohos) cap their SDK at <3.0.0 and so + # cannot resolve under stock Dart 3; they live in pubspec_overrides.ohos.yaml + # and are merged in by scripts/enable-ohos-plugins.sh for OHOS builds. + # NOTE: camera / mobile_scanner / flutter_sound / photo_manager / + # flutter_local_notifications have no ohos port on pub yet — those + # capabilities are missing on HarmonyOS for now. + + # Persisted preferences on OHOS (implements shared_preferences). + shared_preferences_ohos: ^2.2.0 + # TDLib database / files directories on OHOS (implements path_provider). + path_provider_ohos: ^2.2.1 + # External links and the system viewer on OHOS (implements url_launcher). + url_launcher_ohos: ^6.3.0 + # Accelerometer/gyroscope on OHOS — companion plugin whose native side + # serves sensors_plus' channels (sensors_plus has no ohos platform entry). + sensors_plus_ohos: ^0.1.2 + # Deep links (t.me / tg://) on OHOS — companion for app_links. + app_links_ohos: ^0.2.0 + # Opening downloaded files on OHOS (implements open_filex). + open_filex_ohos: ^4.7.0 + # Biometric unlock on OHOS (implements local_auth). + local_auth_ohos: ^1.0.0 + # Auth-key login web view on OHOS (implements webview_flutter). + webview_flutter_ohos: ^3.15.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/pubspec_overrides.ohos.yaml b/pubspec_overrides.ohos.yaml new file mode 100644 index 000000000..fc1a5f943 --- /dev/null +++ b/pubspec_overrides.ohos.yaml @@ -0,0 +1,22 @@ +# OHOS-only plugin overlay — merged into pubspec.yaml by +# scripts/enable-ohos-plugins.sh before a HarmonyOS build, and NOT committed +# into the main pubspec.yaml, because these ports pin `sdk: <3.0.0` on every +# published version and cannot pass version solving under the stock Dart 3 +# SDK used by CI. +# +# After `scripts/enable-ohos-plugins.sh apply`, run `flutter pub get` with the +# OHOS Flutter fork so these resolve. win32 is widened because file_picker_ohos +# pins win32 ^4 while package_info_plus 9.x needs win32 ^5; the widened range +# lets the solver pick one win32 5.x satisfying both (win32 is unused on +# OHOS, which only needs the file_picker_ohos native module). +# +# Still missing on OHOS (no compatible pub.dev port): record (record_ohos +# targets platform-interface v1, host record 7.x needs v2), +# flutter_secure_storage (same v1/v2 platform-interface split), camera, +# mobile_scanner, flutter_sound, photo_manager, flutter_local_notifications. +dependency_overrides: + package_info_plus_ohos: 0.0.7 + permission_handler_ohos: 0.0.8 + file_picker_ohos: 1.0.1 + geolocator_ohos: 0.0.3 + win32: ">=4.1.3 <6.0.0" diff --git a/scripts/enable-ohos-plugins.sh b/scripts/enable-ohos-plugins.sh new file mode 100755 index 000000000..b611da992 --- /dev/null +++ b/scripts/enable-ohos-plugins.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# enable-ohos-plugins.sh — activate the HarmonyOS plugin overlay before an +# OHOS build, and restore the stock pubspec afterwards. +# +# Why: the ohos plugin ports for package_info_plus / permission_handler / +# file_picker / geolocator / flutter_secure_storage / record declare +# `environment: sdk <3.0.0` on every published version, so they cannot sit in +# the main pubspec.yaml — stock CI (`flutter pub get`) would fail version +# solving. The Dart-3-compatible ports (see pubspec.yaml) are regular +# dependencies already; this script layers the blocked ones on top via +# dependency_overrides, which ignores the overridden packages' sdk bounds. +# +# Usage (from the repo root): +# scripts/enable-ohos-plugins.sh apply # merge overlay into pubspec.yaml +# scripts/enable-ohos-plugins.sh revert # restore pubspec.yaml and +# # pubspec.lock from their backups +# +# Safety contract (PR #272 review): the operation is transactional on the two +# files `flutter pub get` rewrites — pubspec.yaml and pubspec.lock. +# +# apply refuses to run when an overlay is already active (a backup exists) +# or when a backup would be overwritten, so repeated apply is a +# no-op refusal, never a loss of the pre-overlay original. +# revert refuses to run when no apply is active, so it can never +# `git checkout` away a pubspec.yaml the user had edited but not +# committed. It restores the exact bytes of both files, including +# uncommitted local edits present at apply time. +# +# Neither command edits any other tracked file, and the script never +# resets, checks out or discards user files. Run with the OHOS Flutter +# fork's `flutter` on PATH, then `flutter pub get` before building; run +# `revert` before switching back to stock builds. +set -euo pipefail + +cd "$(dirname "$0")/.." +OVERLAY="pubspec_overrides.ohos.yaml" +TARGET="pubspec.yaml" +LOCK="pubspec.lock" +BACKUP="pubspec.yaml.ohos-backup" +LOCK_BACKUP="pubspec.lock.ohos-backup" + +die() { echo "enable-ohos-plugins: $*" >&2; exit 1; } + +cmd="${1:-}" +case "$cmd" in + apply|revert) ;; + *) echo "usage: $0 apply|revert" >&2; exit 2 ;; +esac + +if [[ "$cmd" == "revert" ]]; then + [[ -f "$BACKUP" ]] || die "no active overlay to revert (no $BACKUP). Refusing to touch $TARGET so uncommitted edits are not lost; restore it manually if you really meant to." + [[ ! -f "$LOCK_BACKUP" || -f "$LOCK" ]] || die "expected $LOCK with a pending $LOCK_BACKUP" + mv -f "$BACKUP" "$TARGET" + if [[ -f "$LOCK_BACKUP" ]]; then + mv -f "$LOCK_BACKUP" "$LOCK" + else + # The original had no lockfile (the backup's absence records that), so a + # lock that OHOS pub get created during the overlay must not survive the + # revert either: restore absence as well as content. + rm -f "$LOCK" + fi + echo "restored $TARGET and $LOCK from backups" + exit 0 +fi + +# apply: refuse unsafe states instead of discarding work. +[[ -f "$OVERLAY" ]] || die "overlay $OVERLAY not found" +[[ -f "$BACKUP" ]] && die "overlay already active ($BACKUP exists). Run revert first; refusing to overwrite the only backup of the original pubspec." +[[ -f "$LOCK_BACKUP" ]] && die "stale $LOCK_BACKUP left behind. Run revert or remove it manually first." +[[ -f "$TARGET" ]] || die "$TARGET not found" + +python3 - "$TARGET" "$OVERLAY" "$BACKUP" "$LOCK" "$LOCK_BACKUP" <<'PY' +import shutil +import sys + +target, overlay, backup, lock, lock_backup = sys.argv[1:6] +t_text = open(target).read() +o_lines = open(overlay).read().splitlines() + +# Scalar "key: value" entries under the overlay's dependency_overrides section. +ovr, section = {}, None +for raw in o_lines: + if not raw.strip() or raw.lstrip().startswith("#"): + continue + if not raw.startswith((" ", "\t")): + section = raw.rstrip(":").strip() if raw.rstrip().endswith(":") else None + continue + if section == "dependency_overrides" and ":" in raw: + key, _, val = raw.strip().partition(":") + ovr.setdefault(key.strip(), val.strip()) + +# Back up the exact original bytes of both files pub get rewrites: the +# YAML is copied byte-for-byte (newline style included), never re-encoded +# through text mode, so a dirty CRLF file restores byte-identically. +with open(target, "rb") as f: + t_bytes = f.read() +with open(backup, "wb") as f: + f.write(t_bytes) +try: + shutil.copy2(lock, lock_backup) +except FileNotFoundError: + pass # pub get has not run yet; revert then leaves the lock absent too. + +t_lines = t_text.splitlines() + +# Split pubspec.yaml into pre / overrides-body / post around the block. +pre, body, post = [], [], [] +state = "pre" +for l in t_lines: + if state == "pre": + if l == "dependency_overrides:": + state = "body" + continue + pre.append(l) + elif state == "body": + if l and not l[0].isspace(): + state = "post" + post.append(l) + else: + body.append(l) + else: + post.append(l) + +# Keep existing body lines except scalar keys the overlay re-defines. +body_kept = [ + l for l in body + if not (l.strip() and not l.strip().startswith("#") + and l.strip().partition(":")[0].strip() in ovr) +] +appended = [f" {k}: {v}" for k, v in ovr.items()] + +final = pre + ["dependency_overrides:"] + body_kept + appended +final.append("") +final += post + +open(target, "w").write("\n".join(final).rstrip("\n") + "\n") +print(f"merged {len(appended)} overrides into {target} (backups: {backup}, {lock_backup})") +PY + +# The backups and a lockfile rewritten for OHOS must never be committed; the +# committed ignore entries (/pubspec.yaml.ohos-backup, +# /pubspec.lock.ohos-backup) keep them out of the working copy's status. diff --git a/test/enable_ohos_plugins_script_test.dart b/test/enable_ohos_plugins_script_test.dart new file mode 100644 index 000000000..cfb1507aa --- /dev/null +++ b/test/enable_ohos_plugins_script_test.dart @@ -0,0 +1,277 @@ +// Behavioral safety tests for scripts/enable-ohos-plugins.sh (PR #272 review). +// +// The overlay helper must never discard unrelated work. These cases run the +// real script offline (no network, no `flutter pub get`) against a scratch +// copy of the repository's own pubspec files, covering the states the review +// enumerated: a dirty pubspec, repeated apply, revert without a backup, and +// apply/resolve/revert where the lockfile is rewritten in between. +// +// The merge itself is byte-checked against a hand-computed expectation, and +// every destructive refusal is asserted to leave both files untouched. +@Timeout(Duration(minutes: 3)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +class RunResult { + final int code; + final String out; + final String err; + RunResult({required this.code, required this.out, required this.err}); +} + +void main() { + late Directory repo; + late Directory scratch; + late String script; + final pristinePubspec = File('pubspec.yaml').readAsStringSync(); + final pristineLock = File('pubspec.lock').readAsStringSync(); + + setUpAll(() { + repo = Directory.current; + script = '${repo.path}/scripts/enable-ohos-plugins.sh'; + }); + + setUp(() { + scratch = Directory.systemTemp.createTempSync('ohos_overlay_test'); + for (final name in [ + 'pubspec.yaml', + 'pubspec.lock', + 'pubspec_overrides.ohos.yaml', + ]) { + File('${repo.path}/$name').copySync('${scratch.path}/$name'); + } + Directory('${scratch.path}/scripts').createSync(); + File(script).copySync('${scratch.path}/scripts/enable-ohos-plugins.sh'); + }); + + tearDown(() => scratch.deleteSync(recursive: true)); + + RunResult run(List args) { + final result = Process.runSync('bash', [ + 'scripts/enable-ohos-plugins.sh', + ...args, + ], workingDirectory: scratch.path); + return RunResult( + code: result.exitCode, + out: result.stdout.toString(), + err: result.stderr.toString(), + ); + } + + test( + 'apply merges the overlay and keeps a pristine backup of both files', + () { + final r = run(['apply']); + expect(r.code, 0, reason: 'clean apply must succeed\n${r.err}'); + expect(r.out, contains('merged 5 overrides')); + + final merged = File('${scratch.path}/pubspec.yaml').readAsStringSync(); + expect(merged, isNot(pristinePubspec)); + // The fvp git pin (existing dependency_overrides body) survives verbatim. + final pin = RegExp(r'fvp:\n\s+git:\n\s+url:').firstMatch(pristinePubspec); + if (pin != null) { + expect(merged, contains('fvp:')); + } + // The overlay's scalar overrides are appended under the block. + expect(merged, contains('package_info_plus_ohos: 0.0.7')); + expect(merged, contains('geolocator_ohos: 0.0.3')); + // Backups hold the exact pre-apply bytes, uncommitted edits included. + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').readAsStringSync(), + pristinePubspec, + ); + expect( + File('${scratch.path}/pubspec.lock.ohos-backup').readAsStringSync(), + pristineLock, + ); + }, + ); + + test('a second apply refuses instead of overwriting the only backup', () { + expect(run(['apply']).code, 0); + final afterFirstApply = File( + '${scratch.path}/pubspec.yaml', + ).readAsStringSync(); + + final r = run(['apply']); + expect(r.code, isNot(0), reason: 'repeated apply must refuse'); + expect(r.err, contains('already active')); + + // Nothing moved: the backup still holds the original, the target still + // holds the overlaid content (not a backup-of-the-overlay). + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').readAsStringSync(), + pristinePubspec, + ); + expect( + File('${scratch.path}/pubspec.yaml').readAsStringSync(), + afterFirstApply, + ); + }); + + test( + 'apply preserves a dirty pubspec and revert restores its exact bytes', + () { + final dirty = '$pristinePubspec# uncommitted local edit\n'; + File('${scratch.path}/pubspec.yaml').writeAsStringSync(dirty); + + expect(run(['apply']).code, 0); + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').readAsStringSync(), + dirty, + reason: 'the backup must capture pre-existing uncommitted edits', + ); + + // A `flutter pub get` in between rewrites the lock for OHOS. + File( + '${scratch.path}/pubspec.lock', + ).writeAsStringSync('# rewritten for ohos\n'); + expect(run(['revert']).code, 0); + + expect(File('${scratch.path}/pubspec.yaml').readAsStringSync(), dirty); + expect( + File('${scratch.path}/pubspec.lock').readAsStringSync(), + pristineLock, + ); + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').existsSync(), + isFalse, + ); + expect( + File('${scratch.path}/pubspec.lock.ohos-backup').existsSync(), + isFalse, + ); + }, + ); + + test('revert without a backup refuses and never checks out user files', () { + File( + '${scratch.path}/pubspec.yaml', + ).writeAsStringSync('# work in progress\n'); + + final r = run(['revert']); + expect( + r.code, + isNot(0), + reason: 'revert without an active overlay must refuse', + ); + expect(r.err, contains('no active overlay')); + + expect( + File('${scratch.path}/pubspec.yaml').readAsStringSync(), + '# work in progress\n', + reason: 'a refused revert must leave the file byte-identical', + ); + }); + + test('apply/resolve/revert restores the original lock after a rewrite', () { + expect(run(['apply']).code, 0); + + // `flutter pub get` resolves OHOS deps and rewrites pubspec.lock. + File( + '${scratch.path}/pubspec.lock', + ).writeAsStringSync('# resolved for ohos\n'); + expect(run(['revert']).code, 0); + + expect( + File('${scratch.path}/pubspec.yaml').readAsStringSync(), + pristinePubspec, + ); + expect( + File('${scratch.path}/pubspec.lock').readAsStringSync(), + pristineLock, + ); + }); + + test('apply/resolve/revert from a lockless tree restores lock absence', () { + // The original tree had no lockfile: revert must remove the lock the + // overlay's pub get created, not leave it behind as if it predated the + // overlay. + File('${scratch.path}/pubspec.lock').deleteSync(); + expect(run(['apply']).code, 0, reason: 'lockless apply must succeed'); + expect( + File('${scratch.path}/pubspec.lock.ohos-backup').existsSync(), + isFalse, + reason: 'no lock existed, so no lock backup may exist', + ); + // pub get creates a lock during the overlay. + File( + '${scratch.path}/pubspec.lock', + ).writeAsStringSync('# created by OHOS pub get\n'); + + final r = run(['revert']); + expect(r.code, 0, reason: 'revert must succeed\n${r.err}'); + expect( + File('${scratch.path}/pubspec.lock').existsSync(), + isFalse, + reason: 'a lock born during the overlay must not survive the revert', + ); + expect( + File('${scratch.path}/pubspec.yaml').readAsStringSync(), + pristinePubspec, + ); + }); + + test('a dirty CRLF pubspec restores byte-identically', () { + // The backup must preserve the exact bytes — newline style included — + // rather than re-encoding the file through text mode. Byte comparisons + // throughout: Dart's readAsStringSync folds CRLF to LF. + final crlf = pristinePubspec.replaceAll('\n', '\r\n'); + final crlfBytes = utf8.encode(crlf); + File('${scratch.path}/pubspec.yaml').writeAsBytesSync(crlfBytes); + + expect(run(['apply']).code, 0, reason: 'apply on a dirty file'); + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').readAsBytesSync(), + crlfBytes, + reason: 'the backup itself must keep CRLF bytes', + ); + + expect(run(['revert']).code, 0, reason: 'revert must succeed'); + expect( + File('${scratch.path}/pubspec.yaml').readAsBytesSync(), + crlfBytes, + reason: 'the restored file is byte-identical to the dirty original', + ); + }); + + test('apply with a stale lock backup refuses instead of guessing', () { + File( + '${scratch.path}/pubspec.lock.ohos-backup', + ).writeAsStringSync('stale\n'); + + final r = run(['apply']); + expect(r.code, isNot(0)); + expect(r.err, contains('stale')); + expect( + File('${scratch.path}/pubspec.yaml.ohos-backup').existsSync(), + isFalse, + ); + }); + + test('neither command edits tracked .gitignore at runtime', () { + final gitignore = File('${scratch.path}/.gitignore'); + gitignore.writeAsStringSync('# marker\n'); + + expect(run(['apply']).code, 0); + expect(run(['revert']).code, 0); + + expect(gitignore.readAsStringSync(), '# marker\n'); + }); + + test('the script has no runtime edit of .gitignore left in it', () { + // The 2026-10-24 review also flagged the runtime `.gitignore` append; + // the fix removed it, so the script must no longer write to .gitignore. + final text = File(script).readAsStringSync(); + expect( + text.contains('.gitignore'), + isFalse, + reason: + 'the helper must not reference .gitignore at all; entries are committed', + ); + }); +}